@arraypress/waveform-player 1.7.1 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,20 +1,68 @@
1
1
  (() => {
2
2
  // src/js/utils.js
3
+ function escapeHtml(str) {
4
+ return String(str == null ? "" : str).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
5
+ }
6
+ function isSafeHref(url) {
7
+ if (typeof url !== "string" || url === "") return false;
8
+ try {
9
+ const u = new URL(url, "http://localhost/");
10
+ return u.protocol === "http:" || u.protocol === "https:";
11
+ } catch (e) {
12
+ return false;
13
+ }
14
+ }
15
+ function clamp(value, min = 0, max = 1) {
16
+ return Math.max(min, Math.min(value, max));
17
+ }
18
+ function parseBoolAttr(value) {
19
+ return value === void 0 ? void 0 : value === "true";
20
+ }
21
+ function parseColorValue(value) {
22
+ if (typeof value === "string" && value.trim().startsWith("[")) {
23
+ try {
24
+ return JSON.parse(value);
25
+ } catch (e) {
26
+ }
27
+ }
28
+ return value;
29
+ }
3
30
  function parseDataAttributes(element) {
4
31
  const options = {};
32
+ const setBool = (optKey, dataKey = optKey) => {
33
+ const v = parseBoolAttr(element.dataset[dataKey]);
34
+ if (v !== void 0) options[optKey] = v;
35
+ };
36
+ const setNum = (optKey, dataKey = optKey, float = false) => {
37
+ const raw = element.dataset[dataKey];
38
+ if (raw) options[optKey] = float ? parseFloat(raw) : parseInt(raw, 10);
39
+ };
40
+ const setJson = (optKey, dataKey = optKey) => {
41
+ const raw = element.dataset[dataKey];
42
+ if (!raw) return;
43
+ try {
44
+ options[optKey] = JSON.parse(raw);
45
+ } catch (e) {
46
+ console.warn(`[WaveformPlayer] Invalid ${dataKey} JSON:`, e);
47
+ }
48
+ };
49
+ if (element.dataset.src) options.url = element.dataset.src;
5
50
  if (element.dataset.url) options.url = element.dataset.url;
6
- if (element.dataset.height) options.height = parseInt(element.dataset.height);
7
- if (element.dataset.samples) options.samples = parseInt(element.dataset.samples);
51
+ setNum("height");
52
+ setNum("samples");
8
53
  if (element.dataset.preload) {
9
54
  options.preload = element.dataset.preload;
10
55
  }
56
+ if (element.dataset.audioMode) options.audioMode = element.dataset.audioMode;
57
+ if (element.dataset.style) options.waveformStyle = element.dataset.style;
11
58
  if (element.dataset.waveformStyle) options.waveformStyle = element.dataset.waveformStyle;
12
- if (element.dataset.barWidth) options.barWidth = parseInt(element.dataset.barWidth);
13
- if (element.dataset.barSpacing) options.barSpacing = parseInt(element.dataset.barSpacing);
59
+ setNum("barWidth");
60
+ setNum("barSpacing");
61
+ setNum("barRadius");
14
62
  if (element.dataset.buttonAlign) options.buttonAlign = element.dataset.buttonAlign;
15
63
  if (element.dataset.colorPreset) options.colorPreset = element.dataset.colorPreset;
16
- if (element.dataset.waveformColor) options.waveformColor = element.dataset.waveformColor;
17
- if (element.dataset.progressColor) options.progressColor = element.dataset.progressColor;
64
+ if (element.dataset.waveformColor) options.waveformColor = parseColorValue(element.dataset.waveformColor);
65
+ if (element.dataset.progressColor) options.progressColor = parseColorValue(element.dataset.progressColor);
18
66
  if (element.dataset.buttonColor) options.buttonColor = element.dataset.buttonColor;
19
67
  if (element.dataset.buttonHoverColor) options.buttonHoverColor = element.dataset.buttonHoverColor;
20
68
  if (element.dataset.textColor) options.textColor = element.dataset.textColor;
@@ -23,53 +71,50 @@
23
71
  if (element.dataset.borderColor) options.borderColor = element.dataset.borderColor;
24
72
  if (element.dataset.color) options.waveformColor = element.dataset.color;
25
73
  if (element.dataset.theme) options.colorPreset = element.dataset.theme;
26
- if (element.dataset.autoplay) options.autoplay = element.dataset.autoplay === "true";
27
- if (element.dataset.showControls !== void 0) options.showControls = element.dataset.showControls === "true";
28
- if (element.dataset.showInfo !== void 0) options.showInfo = element.dataset.showInfo === "true";
29
- if (element.dataset.showTime) options.showTime = element.dataset.showTime === "true";
30
- if (element.dataset.showHoverTime) options.showHoverTime = element.dataset.showHoverTime === "true";
31
- if (element.dataset.showBpm) options.showBPM = element.dataset.showBpm === "true";
32
- if (element.dataset.singlePlay) options.singlePlay = element.dataset.singlePlay === "true";
33
- if (element.dataset.playOnSeek) options.playOnSeek = element.dataset.playOnSeek === "true";
74
+ setBool("autoplay");
75
+ setBool("showControls");
76
+ setBool("showInfo");
77
+ setBool("showTime");
78
+ setBool("showHoverTime");
79
+ setBool("showBPM", "showBpm");
80
+ setBool("singlePlay");
81
+ setBool("playOnSeek");
34
82
  if (element.dataset.title) options.title = element.dataset.title;
35
83
  if (element.dataset.subtitle) options.subtitle = element.dataset.subtitle;
36
84
  if (element.dataset.album) options.album = element.dataset.album;
37
85
  if (element.dataset.artwork) options.artwork = element.dataset.artwork;
38
86
  if (element.dataset.waveform) options.waveform = element.dataset.waveform;
39
- if (element.dataset.markers) {
40
- try {
41
- options.markers = JSON.parse(element.dataset.markers);
42
- } catch (e) {
43
- console.warn("Invalid markers JSON:", e);
44
- }
45
- }
46
- if (element.dataset.playbackRate) {
47
- options.playbackRate = parseFloat(element.dataset.playbackRate);
48
- }
49
- if (element.dataset.showPlaybackSpeed !== void 0) {
50
- options.showPlaybackSpeed = element.dataset.showPlaybackSpeed === "true";
51
- }
52
- if (element.dataset.playbackRates) {
53
- try {
54
- options.playbackRates = JSON.parse(element.dataset.playbackRates);
55
- } catch (e) {
56
- console.warn("Invalid playbackRates JSON:", e);
57
- }
58
- }
59
- if (element.dataset.enableMediaSession !== void 0) {
60
- options.enableMediaSession = element.dataset.enableMediaSession === "true";
61
- }
87
+ setJson("markers");
88
+ setNum("playbackRate", "playbackRate", true);
89
+ setBool("showPlaybackSpeed");
90
+ setJson("playbackRates");
91
+ setBool("enableMediaSession");
92
+ setBool("showMarkers");
93
+ setBool("accessibleSeek");
94
+ if (element.dataset.seekLabel) options.seekLabel = element.dataset.seekLabel;
95
+ if (element.dataset.errorText) options.errorText = element.dataset.errorText;
96
+ if (element.dataset.playIcon) options.playIcon = element.dataset.playIcon;
97
+ if (element.dataset.pauseIcon) options.pauseIcon = element.dataset.pauseIcon;
62
98
  return options;
63
99
  }
64
100
  function formatTime(seconds) {
65
- if (!seconds || isNaN(seconds)) return "0:00";
66
- const mins = Math.floor(seconds / 60);
101
+ if (!seconds || isNaN(seconds) || seconds < 0) return "0:00";
102
+ const hrs = Math.floor(seconds / 3600);
103
+ const mins = Math.floor(seconds % 3600 / 60);
67
104
  const secs = Math.floor(seconds % 60);
105
+ if (hrs > 0) {
106
+ return `${hrs}:${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
107
+ }
68
108
  return `${mins}:${secs.toString().padStart(2, "0")}`;
69
109
  }
110
+ var idCounter = 0;
70
111
  function generateId(url) {
71
- const str = url || Math.random().toString();
72
- return btoa(str.substring(0, 10)).replace(/[^a-zA-Z0-9]/g, "");
112
+ const str = url || "audio";
113
+ let hash = 5381;
114
+ for (let i = 0; i < str.length; i++) {
115
+ hash = (hash << 5) + hash + str.charCodeAt(i) | 0;
116
+ }
117
+ return `wp_${(hash >>> 0).toString(36)}_${(idCounter++).toString(36)}`;
73
118
  }
74
119
  function extractTitleFromUrl(url) {
75
120
  if (!url) return "Audio";
@@ -78,6 +123,12 @@
78
123
  const name = filename.split(".")[0];
79
124
  return name.replace(/[-_]/g, " ").replace(/\b\w/g, (l) => l.toUpperCase());
80
125
  }
126
+ function perceivedBrightness(color) {
127
+ const rgb = typeof color === "string" ? color.match(/\d+/g) : null;
128
+ if (!rgb || rgb.length < 3) return null;
129
+ const [r, g, b] = rgb.map(Number);
130
+ return (r * 299 + g * 587 + b * 114) / 1e3;
131
+ }
81
132
  function mergeOptions(...sources) {
82
133
  const result = {};
83
134
  for (const source of sources) {
@@ -144,6 +195,42 @@
144
195
  }
145
196
 
146
197
  // src/js/drawing.js
198
+ function makeFill(ctx, value, height) {
199
+ if (!Array.isArray(value)) return value;
200
+ if (value.length === 1) return value[0];
201
+ const grad = ctx.createLinearGradient(0, 0, 0, height);
202
+ value.forEach((c, i) => grad.addColorStop(i / (value.length - 1), c));
203
+ return grad;
204
+ }
205
+ function fillBar(ctx, x, y, w, h, radii) {
206
+ const any = Array.isArray(radii) ? radii.some((r) => r > 0) : radii > 0;
207
+ if (any && typeof ctx.roundRect === "function") {
208
+ const max = Math.min(w / 2, Math.abs(h) / 2);
209
+ const clampR = (r) => clamp(r, 0, max);
210
+ ctx.beginPath();
211
+ ctx.roundRect(x, y, w, h, Array.isArray(radii) ? radii.map(clampR) : clampR(radii));
212
+ ctx.fill();
213
+ } else {
214
+ ctx.fillRect(x, y, w, h);
215
+ }
216
+ }
217
+ function barRadiusPx(options, dpr) {
218
+ return (options.barRadius || 0) * dpr;
219
+ }
220
+ function barRadii(options, dpr) {
221
+ const r = barRadiusPx(options, dpr);
222
+ return [r, r, 0, 0];
223
+ }
224
+ function capsulePath(ctx, startX, endX, centerY, barHeight) {
225
+ const r = barHeight / 2;
226
+ ctx.beginPath();
227
+ ctx.moveTo(startX, centerY - r);
228
+ ctx.lineTo(endX - r, centerY - r);
229
+ ctx.arc(endX - r, centerY, r, -Math.PI / 2, Math.PI / 2);
230
+ ctx.lineTo(startX, centerY + r);
231
+ ctx.arc(startX, centerY, r, Math.PI / 2, -Math.PI / 2);
232
+ ctx.closePath();
233
+ }
147
234
  function drawBars(ctx, canvas, peaks, progress, options) {
148
235
  const dpr = window.devicePixelRatio || 1;
149
236
  const barWidth = options.barWidth * dpr;
@@ -152,26 +239,29 @@
152
239
  const resampledPeaks = resampleData(peaks, barCount);
153
240
  const height = canvas.height;
154
241
  const progressWidth = progress * canvas.width;
242
+ const radii = barRadii(options, dpr);
243
+ const baseFill = makeFill(ctx, options.color, height);
244
+ const progFill = makeFill(ctx, options.progressColor, height);
155
245
  ctx.clearRect(0, 0, canvas.width, canvas.height);
246
+ ctx.fillStyle = baseFill;
156
247
  for (let i = 0; i < resampledPeaks.length; i++) {
157
248
  const x = i * (barWidth + barSpacing);
158
249
  if (x + barWidth > canvas.width) break;
159
250
  const peakHeight = resampledPeaks[i] * height * 0.9;
160
251
  const y = height - peakHeight;
161
- ctx.fillStyle = options.color;
162
- ctx.fillRect(x, y, barWidth, peakHeight);
252
+ fillBar(ctx, x, y, barWidth, peakHeight, radii);
163
253
  }
164
254
  ctx.save();
165
255
  ctx.beginPath();
166
256
  ctx.rect(0, 0, progressWidth, height);
167
257
  ctx.clip();
258
+ ctx.fillStyle = progFill;
168
259
  for (let i = 0; i < resampledPeaks.length; i++) {
169
260
  const x = i * (barWidth + barSpacing);
170
261
  if (x > progressWidth) break;
171
262
  const peakHeight = resampledPeaks[i] * height * 0.9;
172
263
  const y = height - peakHeight;
173
- ctx.fillStyle = options.progressColor;
174
- ctx.fillRect(x, y, barWidth, peakHeight);
264
+ fillBar(ctx, x, y, barWidth, peakHeight, radii);
175
265
  }
176
266
  ctx.restore();
177
267
  }
@@ -184,26 +274,31 @@
184
274
  const height = canvas.height;
185
275
  const centerY = height / 2;
186
276
  const progressWidth = progress * canvas.width;
277
+ const r = barRadiusPx(options, dpr);
278
+ const topRadii = [r, r, 0, 0];
279
+ const botRadii = [0, 0, r, r];
280
+ const baseFill = makeFill(ctx, options.color, height);
281
+ const progFill = makeFill(ctx, options.progressColor, height);
187
282
  ctx.clearRect(0, 0, canvas.width, canvas.height);
283
+ ctx.fillStyle = baseFill;
188
284
  for (let i = 0; i < resampledPeaks.length; i++) {
189
285
  const x = i * (barWidth + barSpacing);
190
286
  if (x + barWidth > canvas.width) break;
191
287
  const peakHeight = resampledPeaks[i] * height * 0.45;
192
- ctx.fillStyle = options.color;
193
- ctx.fillRect(x, centerY - peakHeight, barWidth, peakHeight);
194
- ctx.fillRect(x, centerY, barWidth, peakHeight);
288
+ fillBar(ctx, x, centerY - peakHeight, barWidth, peakHeight, topRadii);
289
+ fillBar(ctx, x, centerY, barWidth, peakHeight, botRadii);
195
290
  }
196
291
  ctx.save();
197
292
  ctx.beginPath();
198
293
  ctx.rect(0, 0, progressWidth, height);
199
294
  ctx.clip();
295
+ ctx.fillStyle = progFill;
200
296
  for (let i = 0; i < resampledPeaks.length; i++) {
201
297
  const x = i * (barWidth + barSpacing);
202
298
  if (x > progressWidth) break;
203
299
  const peakHeight = resampledPeaks[i] * height * 0.45;
204
- ctx.fillStyle = options.progressColor;
205
- ctx.fillRect(x, centerY - peakHeight, barWidth, peakHeight);
206
- ctx.fillRect(x, centerY, barWidth, peakHeight);
300
+ fillBar(ctx, x, centerY - peakHeight, barWidth, peakHeight, topRadii);
301
+ fillBar(ctx, x, centerY, barWidth, peakHeight, botRadii);
207
302
  }
208
303
  ctx.restore();
209
304
  }
@@ -274,13 +369,15 @@
274
369
  const blockGap = 2 * dpr;
275
370
  const progressWidth = progress * canvas.width;
276
371
  const centerY = height / 2;
372
+ const baseFill = makeFill(ctx, options.color, height);
373
+ const progFill = makeFill(ctx, options.progressColor, height);
277
374
  ctx.clearRect(0, 0, canvas.width, canvas.height);
278
375
  for (let i = 0; i < resampledPeaks.length; i++) {
279
376
  const x = i * (barWidth + barSpacing);
280
377
  if (x + barWidth > canvas.width) break;
281
378
  const peakHeight = resampledPeaks[i] * height * 0.9;
282
379
  const blockCount = Math.floor(peakHeight / (blockSize + blockGap));
283
- ctx.fillStyle = x < progressWidth ? options.progressColor : options.color;
380
+ ctx.fillStyle = x < progressWidth ? progFill : baseFill;
284
381
  for (let j = 0; j < blockCount; j++) {
285
382
  const blockOffset = j * (blockSize + blockGap);
286
383
  ctx.fillRect(x, centerY - blockOffset - blockSize, barWidth, blockSize);
@@ -300,12 +397,14 @@
300
397
  const dotRadius = Math.max(1.5 * dpr, barWidth / 2);
301
398
  const progressWidth = progress * canvas.width;
302
399
  const centerY = height / 2;
400
+ const baseFill = makeFill(ctx, options.color, height);
401
+ const progFill = makeFill(ctx, options.progressColor, height);
303
402
  ctx.clearRect(0, 0, canvas.width, canvas.height);
304
403
  for (let i = 0; i < resampledPeaks.length; i++) {
305
404
  const x = i * (barWidth + barSpacing) + barWidth / 2;
306
405
  if (x > canvas.width) break;
307
406
  const peakHeight = resampledPeaks[i] * height * 0.9;
308
- ctx.fillStyle = x < progressWidth ? options.progressColor : options.color;
407
+ ctx.fillStyle = x < progressWidth ? progFill : baseFill;
309
408
  ctx.beginPath();
310
409
  ctx.arc(x, centerY - peakHeight / 2, dotRadius, 0, Math.PI * 2);
311
410
  ctx.fill();
@@ -322,26 +421,14 @@
322
421
  const borderRadius = barHeight / 2;
323
422
  ctx.clearRect(0, 0, width, height);
324
423
  ctx.fillStyle = options.color || "rgba(255, 255, 255, 0.2)";
325
- ctx.beginPath();
326
- ctx.moveTo(borderRadius, centerY - barHeight / 2);
327
- ctx.lineTo(width - borderRadius, centerY - barHeight / 2);
328
- ctx.arc(width - borderRadius, centerY, barHeight / 2, -Math.PI / 2, Math.PI / 2);
329
- ctx.lineTo(borderRadius, centerY + barHeight / 2);
330
- ctx.arc(borderRadius, centerY, barHeight / 2, Math.PI / 2, -Math.PI / 2);
331
- ctx.closePath();
424
+ capsulePath(ctx, borderRadius, width, centerY, barHeight);
332
425
  ctx.fill();
333
426
  if (progress > 0) {
334
427
  const progressWidth = Math.max(borderRadius * 2, progress * width);
335
428
  ctx.shadowBlur = 8;
336
429
  ctx.shadowColor = options.progressColor;
337
430
  ctx.fillStyle = options.progressColor || "rgba(255, 255, 255, 0.9)";
338
- ctx.beginPath();
339
- ctx.moveTo(borderRadius, centerY - barHeight / 2);
340
- ctx.lineTo(progressWidth - borderRadius, centerY - barHeight / 2);
341
- ctx.arc(progressWidth - borderRadius, centerY, barHeight / 2, -Math.PI / 2, Math.PI / 2);
342
- ctx.lineTo(borderRadius, centerY + barHeight / 2);
343
- ctx.arc(borderRadius, centerY, barHeight / 2, Math.PI / 2, -Math.PI / 2);
344
- ctx.closePath();
431
+ capsulePath(ctx, borderRadius, progressWidth, centerY, barHeight);
345
432
  ctx.fill();
346
433
  ctx.shadowBlur = 0;
347
434
  const handleRadius = 8;
@@ -417,7 +504,7 @@
417
504
  }
418
505
  return detectedBPM - 1;
419
506
  } catch (e) {
420
- console.warn("BPM detection failed:", e);
507
+ console.warn("[WaveformPlayer] BPM detection failed:", e);
421
508
  return null;
422
509
  }
423
510
  }
@@ -474,8 +561,11 @@
474
561
  return maxPeak > 0 ? peaks.map((peak) => peak / maxPeak) : peaks;
475
562
  }
476
563
  async function generateWaveform(url, samples = 200, shouldDetectBPM = false) {
564
+ let audioContext;
477
565
  try {
478
- const audioContext = new (window.AudioContext || window.webkitAudioContext)();
566
+ const AudioCtx = window.AudioContext || /** @type {any} */
567
+ window.webkitAudioContext;
568
+ audioContext = new AudioCtx();
479
569
  const response = await fetch(url);
480
570
  const arrayBuffer = await response.arrayBuffer();
481
571
  const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
@@ -483,13 +573,11 @@
483
573
  peaks = normalizePeaks(peaks);
484
574
  let bpm = null;
485
575
  if (shouldDetectBPM) {
486
- bpm = await detectBPM(audioBuffer);
576
+ bpm = detectBPM(audioBuffer);
487
577
  }
488
- audioContext.close();
489
578
  return { peaks, bpm };
490
- } catch (error) {
491
- console.error("Failed to generate waveform:", error);
492
- throw error;
579
+ } finally {
580
+ if (audioContext) audioContext.close();
493
581
  }
494
582
  }
495
583
  function generatePlaceholderWaveform(samples = 200) {
@@ -497,7 +585,7 @@
497
585
  for (let i = 0; i < samples; i++) {
498
586
  const base = Math.random() * 0.5 + 0.3;
499
587
  const variation = Math.sin(i / samples * Math.PI * 4) * 0.2;
500
- data.push(Math.max(0.1, Math.min(1, base + variation)));
588
+ data.push(clamp(base + variation, 0.1, 1));
501
589
  }
502
590
  return data;
503
591
  }
@@ -509,26 +597,20 @@
509
597
  }
510
598
 
511
599
  // src/js/themes.js
512
- function detectColorScheme() {
600
+ function hasThemeHint(scheme) {
513
601
  const root = document.documentElement;
514
602
  const body = document.body;
515
- if (root.classList.contains("dark") || root.classList.contains("dark-mode") || root.classList.contains("theme-dark") || root.getAttribute("data-theme") === "dark" || root.getAttribute("data-color-scheme") === "dark" || body.classList.contains("dark") || body.classList.contains("dark-mode") || body.getAttribute("data-theme") === "dark") {
516
- return "dark";
517
- }
518
- if (root.classList.contains("light") || root.classList.contains("light-mode") || root.classList.contains("theme-light") || root.getAttribute("data-theme") === "light" || root.getAttribute("data-color-scheme") === "light" || body.classList.contains("light") || body.classList.contains("light-mode") || body.getAttribute("data-theme") === "light") {
519
- return "light";
520
- }
603
+ return root.classList.contains(scheme) || root.classList.contains(`${scheme}-mode`) || root.classList.contains(`theme-${scheme}`) || root.getAttribute("data-theme") === scheme || root.getAttribute("data-color-scheme") === scheme || body.classList.contains(scheme) || body.classList.contains(`${scheme}-mode`) || body.getAttribute("data-theme") === scheme;
604
+ }
605
+ function detectColorScheme() {
606
+ if (hasThemeHint("dark")) return "dark";
607
+ if (hasThemeHint("light")) return "light";
521
608
  try {
522
609
  const bodyBg = getComputedStyle(document.body).backgroundColor;
523
- const rgb = bodyBg.match(/\d+/g);
524
- if (rgb && rgb.length >= 3) {
525
- const [r, g, b] = rgb.map(Number);
526
- const brightness = (r * 299 + g * 587 + b * 114) / 1e3;
527
- if (brightness > 128) {
528
- return "light";
529
- } else if (brightness < 128) {
530
- return "dark";
531
- }
610
+ const brightness = perceivedBrightness(bodyBg);
611
+ if (brightness !== null) {
612
+ if (brightness > 128) return "light";
613
+ if (brightness < 128) return "dark";
532
614
  }
533
615
  } catch (e) {
534
616
  }
@@ -593,6 +675,8 @@
593
675
  waveformStyle: "mirror",
594
676
  barWidth: 2,
595
677
  barSpacing: 0,
678
+ // Rounded bar caps (px). 0 = square (default). Applies to bars/mirror.
679
+ barRadius: 0,
596
680
  // Color preset: null = auto-detect, 'dark' = force dark, 'light' = force light
597
681
  colorPreset: null,
598
682
  // Individual color overrides (null means use preset)
@@ -617,11 +701,19 @@
617
701
  // Markers
618
702
  markers: [],
619
703
  showMarkers: true,
704
+ // Accessibility — expose the waveform as a keyboard-operable slider
705
+ // (role="slider" + ARIA value attributes + arrow/page/home/end seeking).
706
+ // seekLabel sets the slider's accessible name; when null it falls back
707
+ // to the track title, then 'Seek'.
708
+ accessibleSeek: true,
709
+ seekLabel: null,
620
710
  // Content
621
711
  title: null,
622
712
  subtitle: null,
623
713
  artwork: null,
624
714
  album: "",
715
+ // Message shown in the error state when audio fails to load.
716
+ errorText: "Unable to load audio",
625
717
  // Icons (SVG)
626
718
  playIcon: '<svg viewBox="0 0 24 24" width="16" height="16"><path d="M8 5v14l11-7z"/></svg>',
627
719
  pauseIcon: '<svg viewBox="0 0 24 24" width="16" height="16"><path d="M6 4h4v16H6zM14 4h4v16h-4z"/></svg>',
@@ -643,23 +735,40 @@
643
735
  };
644
736
 
645
737
  // src/js/core.js
738
+ var SEEK_STEP_SECONDS = 5;
739
+ var SEEK_PAGE_SECONDS = 10;
646
740
  var WaveformPlayer = class _WaveformPlayer {
647
741
  /** @type {Map<string, WaveformPlayer>} */
648
742
  static instances = /* @__PURE__ */ new Map();
649
743
  /** @type {WaveformPlayer|null} */
650
744
  static currentlyPlaying = null;
651
745
  /**
652
- * Create a new WaveformPlayer instance
653
- * @param {string|HTMLElement} container - Container element or selector
654
- * @param {Object} options - Player options
746
+ * Create a new WaveformPlayer instance.
747
+ *
748
+ * Resolves the container, merges options (defaults < `data-*` attributes <
749
+ * constructor options), applies the colour preset and style-specific
750
+ * defaults, registers the instance in the static map, and kicks off
751
+ * {@link WaveformPlayer#init}. A `waveformplayer:ready` event is dispatched
752
+ * ~100ms later, once initialization has settled.
753
+ *
754
+ * @param {string|HTMLElement} container - Container element, or a CSS
755
+ * selector resolved with `document.querySelector`.
756
+ * @param {Object} [options={}] - Player options. Accepts the shorthand
757
+ * aliases `style` (→ `waveformStyle`) and `src` (→ `url`); the canonical
758
+ * names win if both are supplied.
759
+ * @throws {Error} If the container element cannot be found.
760
+ * @fires WaveformPlayer#waveformplayer:ready
655
761
  */
656
762
  constructor(container, options = {}) {
657
763
  this.container = typeof container === "string" ? document.querySelector(container) : container;
658
764
  if (!this.container) {
659
- throw new Error("WaveformPlayer: Container element not found");
765
+ throw new Error("[WaveformPlayer] Container element not found");
660
766
  }
661
767
  const dataOptions = parseDataAttributes(this.container);
662
- this.options = mergeOptions(DEFAULT_OPTIONS, dataOptions, options);
768
+ const userOptions = { ...options };
769
+ if (userOptions.style && !userOptions.waveformStyle) userOptions.waveformStyle = userOptions.style;
770
+ if (userOptions.src && !userOptions.url) userOptions.url = userOptions.src;
771
+ this.options = mergeOptions(DEFAULT_OPTIONS, dataOptions, userOptions);
663
772
  const preset = getColorPreset(this.options.colorPreset);
664
773
  for (const [key, value] of Object.entries(preset)) {
665
774
  if (this.options[key] === null || this.options[key] === void 0) {
@@ -685,21 +794,54 @@
685
794
  this.hasError = false;
686
795
  this.updateTimer = null;
687
796
  this.resizeObserver = null;
797
+ this._ac = new AbortController();
688
798
  this.id = this.container.id || generateId(this.options.url);
689
799
  _WaveformPlayer.instances.set(this.id, this);
690
800
  this.init();
691
801
  setTimeout(() => {
692
- this.container.dispatchEvent(new CustomEvent("waveformplayer:ready", {
693
- bubbles: true,
694
- detail: { player: this, url: this.options.url }
695
- }));
802
+ this._emit("waveformplayer:ready", { player: this, url: this.options.url });
696
803
  }, 100);
697
804
  }
805
+ /**
806
+ * Build and dispatch a bubbling `waveformplayer:*` CustomEvent on the
807
+ * container, returning the event so cancelable (request-*) events can have
808
+ * their `defaultPrevented` checked. Single source of truth for the event
809
+ * shape — every player event bubbles and carries the supplied detail.
810
+ * @param {string} type - Full event type, e.g. `'waveformplayer:play'`.
811
+ * @param {Object} detail - Event detail payload.
812
+ * @param {boolean} [cancelable=false] - Whether the event is cancelable.
813
+ * @returns {CustomEvent} The dispatched event.
814
+ * @private
815
+ */
816
+ _emit(type, detail, cancelable = false) {
817
+ const event = new CustomEvent(type, { bubbles: true, cancelable, detail });
818
+ this.container.dispatchEvent(event);
819
+ return event;
820
+ }
821
+ /**
822
+ * External-mode seek request: dispatch a cancelable
823
+ * `waveformplayer:request-seek` and, unless the controller calls
824
+ * `preventDefault()`, optimistically advance the local progress overlay so
825
+ * the canvas repaints at once. Shared by the keyboard slider and canvas click.
826
+ * @param {number} percent - Target position as a 0..1 fraction.
827
+ * @private
828
+ * @fires WaveformPlayer#waveformplayer:request-seek
829
+ */
830
+ _requestSeek(percent) {
831
+ const evt = this._emit("waveformplayer:request-seek", { ...this._buildTrackDetail(), percent }, true);
832
+ if (!evt.defaultPrevented) {
833
+ this.progress = percent;
834
+ this.drawWaveform?.();
835
+ }
836
+ }
698
837
  // ============================================
699
838
  // Initialization
700
839
  // ============================================
701
840
  /**
702
- * Initialize the player
841
+ * Initialize the player: build the DOM, create the audio element (self
842
+ * mode only), wire up the feature controls (speed, keyboard, accessible
843
+ * seek), bind events, attach the resize observer, then size the canvas and
844
+ * — if a `url` option was given — load it and optionally autoplay.
703
845
  * @private
704
846
  */
705
847
  init() {
@@ -707,6 +849,7 @@
707
849
  this.createAudio();
708
850
  this.initPlaybackSpeed();
709
851
  this.initKeyboardControls();
852
+ this.initSeekControl();
710
853
  this.bindEvents();
711
854
  this.setupResizeObserver();
712
855
  requestAnimationFrame(() => {
@@ -714,16 +857,24 @@
714
857
  if (this.options.url) {
715
858
  this.load(this.options.url).then(() => {
716
859
  if (this.options.autoplay) {
717
- this.play();
860
+ this.play()?.catch(() => {
861
+ });
718
862
  }
719
863
  }).catch((error) => {
720
- console.error("Failed to load audio:", error);
864
+ console.error("[WaveformPlayer] Failed to load audio:", error);
721
865
  });
722
866
  }
723
867
  });
724
868
  }
725
869
  /**
726
- * Create DOM elements
870
+ * Build the player's DOM tree inside the container and cache element
871
+ * references.
872
+ *
873
+ * Clears the container, resolves button alignment (`auto` → `bottom` for
874
+ * the `bars` style, `center` otherwise), and conditionally renders the play
875
+ * button, info row (artwork/title/subtitle), BPM badge, playback-speed
876
+ * menu, and time display based on the relevant `show*` options. Caches the
877
+ * canvas, controls, and text elements onto `this`, then sizes the canvas.
727
878
  * @private
728
879
  */
729
880
  createDOM() {
@@ -798,8 +949,8 @@
798
949
  <canvas></canvas>
799
950
  <div class="waveform-markers"></div>
800
951
  <div class="waveform-loading" style="display:none;"></div>
801
- <div class="waveform-error" style="display:none;">
802
- <span class="waveform-error-text">Unable to load audio</span>
952
+ <div class="waveform-error" style="display:none;" role="alert">
953
+ <span class="waveform-error-text">${escapeHtml(this.options.errorText)}</span>
803
954
  </div>
804
955
  </div>
805
956
  </div>
@@ -848,7 +999,9 @@
848
999
  // Feature Initialization
849
1000
  // ============================================
850
1001
  /**
851
- * Initialize playback speed controls
1002
+ * Apply the configured initial playback rate to the audio element (self
1003
+ * mode only) and, when `showPlaybackSpeed` is enabled, wire up the speed
1004
+ * menu UI via {@link WaveformPlayer#initSpeedControls}.
852
1005
  * @private
853
1006
  */
854
1007
  initPlaybackSpeed() {
@@ -860,7 +1013,11 @@
860
1013
  }
861
1014
  }
862
1015
  /**
863
- * Initialize speed control UI
1016
+ * Wire up the playback-speed menu: toggle it open on the speed button,
1017
+ * close it on any outside click, and apply the chosen rate when a
1018
+ * `.speed-option` is clicked. All listeners are registered against the
1019
+ * instance `AbortController` signal so {@link WaveformPlayer#destroy} tears
1020
+ * them down. No-op if the speed elements are absent.
864
1021
  * @private
865
1022
  */
866
1023
  initSpeedControls() {
@@ -870,10 +1027,10 @@
870
1027
  speedBtn.addEventListener("click", (e) => {
871
1028
  e.stopPropagation();
872
1029
  speedMenu.style.display = speedMenu.style.display === "none" ? "block" : "none";
873
- });
1030
+ }, { signal: this._ac.signal });
874
1031
  document.addEventListener("click", () => {
875
1032
  speedMenu.style.display = "none";
876
- });
1033
+ }, { signal: this._ac.signal });
877
1034
  speedMenu.addEventListener("click", (e) => {
878
1035
  e.stopPropagation();
879
1036
  if (e.target.classList.contains("speed-option")) {
@@ -881,11 +1038,18 @@
881
1038
  this.setPlaybackRate(rate);
882
1039
  speedMenu.style.display = "none";
883
1040
  }
884
- });
1041
+ }, { signal: this._ac.signal });
885
1042
  this.updateSpeedUI();
886
1043
  }
887
1044
  /**
888
- * Initialize keyboard controls
1045
+ * Enable keyboard transport controls on the container.
1046
+ *
1047
+ * The container is focusable only after it is clicked (it carries
1048
+ * `tabindex="-1"` until then, and clicking steals focus from sibling
1049
+ * players). While focused it handles: digits 0-9 (seek to that tenth of
1050
+ * the track), Space (toggle play), and — in self mode only, since
1051
+ * `this.audio` is null in external mode — arrow keys (seek ±5s, volume
1052
+ * ±0.1) and `m`/`M` (mute). Listeners use the instance abort signal.
889
1053
  * @private
890
1054
  */
891
1055
  initKeyboardControls() {
@@ -898,7 +1062,7 @@
898
1062
  });
899
1063
  this.container.setAttribute("tabindex", "0");
900
1064
  this.container.focus();
901
- });
1065
+ }, { signal: this._ac.signal });
902
1066
  this.container.addEventListener("keydown", (e) => {
903
1067
  if (document.activeElement !== this.container) return;
904
1068
  const key = e.key;
@@ -913,17 +1077,141 @@
913
1077
  " ": () => this.togglePlay()
914
1078
  };
915
1079
  if (hasAudio) {
916
- actions["ArrowLeft"] = () => this.seekTo(Math.max(0, currentTime - 5));
917
- actions["ArrowRight"] = () => this.seekTo(Math.min(this.audio.duration, currentTime + 5));
918
- actions["ArrowUp"] = () => this.setVolume(Math.min(1, this.audio.volume + 0.1));
919
- actions["ArrowDown"] = () => this.setVolume(Math.max(0, this.audio.volume - 0.1));
1080
+ actions["ArrowLeft"] = () => this.seekTo(clamp(currentTime - 5, 0, this.audio.duration));
1081
+ actions["ArrowRight"] = () => this.seekTo(clamp(currentTime + 5, 0, this.audio.duration));
1082
+ actions["ArrowUp"] = () => this.setVolume(clamp(this.audio.volume + 0.1));
1083
+ actions["ArrowDown"] = () => this.setVolume(clamp(this.audio.volume - 0.1));
920
1084
  actions["m"] = actions["M"] = () => this.audio.muted = !this.audio.muted;
921
1085
  }
922
1086
  if (actions[key]) {
923
1087
  e.preventDefault();
924
1088
  actions[key]();
925
1089
  }
926
- });
1090
+ }, { signal: this._ac.signal });
1091
+ }
1092
+ /**
1093
+ * Expose the waveform as an accessible, keyboard-operable slider.
1094
+ *
1095
+ * Adds role="slider" + ARIA value attributes to the waveform surface,
1096
+ * makes it focusable in the tab order, and handles the standard slider
1097
+ * keys (arrows, Page Up/Down, Home/End) to seek. Works in both self and
1098
+ * external audio modes. Opt out with `accessibleSeek: false`.
1099
+ * @private
1100
+ */
1101
+ initSeekControl() {
1102
+ if (!this.options.accessibleSeek) return;
1103
+ this.seekEl = this.container.querySelector(".waveform-container");
1104
+ if (!this.seekEl) return;
1105
+ this.seekEl.setAttribute("role", "slider");
1106
+ this.seekEl.setAttribute("tabindex", "0");
1107
+ this.seekEl.setAttribute("aria-valuemin", "0");
1108
+ this.applySeekLabel();
1109
+ this.updateSeekAccessibility();
1110
+ this.seekEl.addEventListener("keydown", (e) => {
1111
+ const duration = this.getSeekDuration();
1112
+ if (!duration) return;
1113
+ const current = this.getSeekCurrentTime();
1114
+ let target;
1115
+ switch (e.key) {
1116
+ case "ArrowLeft":
1117
+ case "ArrowDown":
1118
+ target = current - SEEK_STEP_SECONDS;
1119
+ break;
1120
+ case "ArrowRight":
1121
+ case "ArrowUp":
1122
+ target = current + SEEK_STEP_SECONDS;
1123
+ break;
1124
+ case "PageDown":
1125
+ target = current - SEEK_PAGE_SECONDS;
1126
+ break;
1127
+ case "PageUp":
1128
+ target = current + SEEK_PAGE_SECONDS;
1129
+ break;
1130
+ case "Home":
1131
+ target = 0;
1132
+ break;
1133
+ case "End":
1134
+ target = duration;
1135
+ break;
1136
+ default:
1137
+ return;
1138
+ }
1139
+ e.preventDefault();
1140
+ e.stopPropagation();
1141
+ this.seekToSeconds(target);
1142
+ }, { signal: this._ac.signal });
1143
+ }
1144
+ /**
1145
+ * Total seekable duration in seconds, regardless of audio mode.
1146
+ * @returns {number}
1147
+ * @private
1148
+ */
1149
+ getSeekDuration() {
1150
+ if (this.options.audioMode === "external") {
1151
+ return this._extDuration || 0;
1152
+ }
1153
+ return this.audio && Number.isFinite(this.audio.duration) ? this.audio.duration : 0;
1154
+ }
1155
+ /**
1156
+ * Current playback position in seconds, regardless of audio mode.
1157
+ * @returns {number}
1158
+ * @private
1159
+ */
1160
+ getSeekCurrentTime() {
1161
+ if (this.options.audioMode === "external") {
1162
+ return this.progress * (this._extDuration || 0);
1163
+ }
1164
+ return this.audio && Number.isFinite(this.audio.currentTime) ? this.audio.currentTime : 0;
1165
+ }
1166
+ /**
1167
+ * Seek the slider to an absolute time, clamped to the track length.
1168
+ *
1169
+ * In self mode this defers to {@link WaveformPlayer#seekTo}. In external
1170
+ * mode it dispatches a cancelable `waveformplayer:request-seek` event with
1171
+ * the target percentage; if the controller doesn't `preventDefault()`, the
1172
+ * local progress/visual is updated optimistically. Either way the ARIA
1173
+ * slider values are refreshed.
1174
+ * @param {number} seconds - Target time in seconds.
1175
+ * @private
1176
+ * @fires WaveformPlayer#waveformplayer:request-seek
1177
+ */
1178
+ seekToSeconds(seconds) {
1179
+ const duration = this.getSeekDuration();
1180
+ if (!duration) return;
1181
+ const clamped = clamp(seconds, 0, duration);
1182
+ if (this.options.audioMode === "external") {
1183
+ this._requestSeek(clamped / duration);
1184
+ this.updateSeekAccessibility();
1185
+ return;
1186
+ }
1187
+ this.seekTo(clamped);
1188
+ }
1189
+ /**
1190
+ * Set the slider's accessible name from `seekLabel`, falling back to the
1191
+ * track title, then a generic 'Seek'. No-op if the slider isn't present.
1192
+ * @param {string} [title=this.options.title] - Track title to fall back to
1193
+ * when `seekLabel` is not set.
1194
+ * @private
1195
+ */
1196
+ applySeekLabel(title = this.options.title) {
1197
+ if (!this.seekEl) return;
1198
+ const label = this.options.seekLabel || title || "Seek";
1199
+ this.seekEl.setAttribute("aria-label", label);
1200
+ }
1201
+ /**
1202
+ * Keep the slider's ARIA value attributes in sync with playback.
1203
+ * @private
1204
+ */
1205
+ updateSeekAccessibility() {
1206
+ if (!this.seekEl) return;
1207
+ const duration = this.getSeekDuration();
1208
+ const current = Math.min(this.getSeekCurrentTime(), duration);
1209
+ this.seekEl.setAttribute("aria-valuemax", String(Math.round(duration)));
1210
+ this.seekEl.setAttribute("aria-valuenow", String(Math.round(current)));
1211
+ this.seekEl.setAttribute(
1212
+ "aria-valuetext",
1213
+ `${formatTime(current)} of ${formatTime(duration)}`
1214
+ );
927
1215
  }
928
1216
  /**
929
1217
  * Initialize Media Session API for system media controls
@@ -943,10 +1231,10 @@
943
1231
  navigator.mediaSession.setActionHandler("play", () => this.play());
944
1232
  navigator.mediaSession.setActionHandler("pause", () => this.pause());
945
1233
  navigator.mediaSession.setActionHandler("seekbackward", () => {
946
- this.seekTo(Math.max(0, this.audio.currentTime - 10));
1234
+ this.seekTo(clamp(this.audio.currentTime - 10, 0, this.audio.duration));
947
1235
  });
948
1236
  navigator.mediaSession.setActionHandler("seekforward", () => {
949
- this.seekTo(Math.min(this.audio.duration, this.audio.currentTime + 10));
1237
+ this.seekTo(clamp(this.audio.currentTime + 10, 0, this.audio.duration));
950
1238
  });
951
1239
  navigator.mediaSession.setActionHandler("seekto", (details) => {
952
1240
  if (details.seekTime !== null) {
@@ -958,7 +1246,10 @@
958
1246
  // Event Binding
959
1247
  // ============================================
960
1248
  /**
961
- * Bind event listeners
1249
+ * Bind the core interaction listeners: play-button click, the `<audio>`
1250
+ * media events (self mode only — external mode is fed state via
1251
+ * {@link WaveformPlayer#setPlayingState}/{@link WaveformPlayer#setProgress}),
1252
+ * canvas click-to-seek, and a debounced window-resize redraw.
962
1253
  * @private
963
1254
  */
964
1255
  bindEvents() {
@@ -979,7 +1270,8 @@
979
1270
  window.addEventListener("resize", this.resizeHandler);
980
1271
  }
981
1272
  /**
982
- * Setup resize observer
1273
+ * Observe the canvas's parent element for size changes and re-fit the
1274
+ * canvas on each one. No-op where `ResizeObserver` is unavailable.
983
1275
  * @private
984
1276
  */
985
1277
  setupResizeObserver() {
@@ -996,9 +1288,20 @@
996
1288
  // Audio Loading
997
1289
  // ============================================
998
1290
  /**
999
- * Load audio file
1000
- * @param {string} url - Audio URL
1001
- * @returns {Promise<void>}
1291
+ * Load an audio source: set the title, fetch/generate the waveform peaks,
1292
+ * draw them, render markers, and initialise Media Session.
1293
+ *
1294
+ * In self mode the `<audio>` src is assigned and the method awaits
1295
+ * `loadedmetadata` before proceeding. In external mode there is no audio
1296
+ * element, so the src/metadata step is skipped and only the visualization
1297
+ * is built (duration/time come from the controller via
1298
+ * {@link WaveformPlayer#setProgress}). Peaks come from the `waveform`
1299
+ * option when provided, otherwise they are decoded from the audio; a
1300
+ * decode failure falls back to a placeholder waveform. The `onLoad`
1301
+ * callback fires on success.
1302
+ * @param {string} url - Audio URL.
1303
+ * @returns {Promise<void>} Resolves once loading settles (errors are caught
1304
+ * internally and surfaced through {@link WaveformPlayer#onError}).
1002
1305
  */
1003
1306
  async load(url) {
1004
1307
  try {
@@ -1026,6 +1329,7 @@
1026
1329
  if (this.titleEl) {
1027
1330
  this.titleEl.textContent = title;
1028
1331
  }
1332
+ this.applySeekLabel(title);
1029
1333
  if (this.options.waveform) {
1030
1334
  this.setWaveformData(this.options.waveform);
1031
1335
  } else {
@@ -1037,7 +1341,7 @@
1037
1341
  this.updateBPMDisplay();
1038
1342
  }
1039
1343
  } catch (error) {
1040
- console.warn("Using placeholder waveform:", error);
1344
+ console.warn("[WaveformPlayer] Using placeholder waveform:", error);
1041
1345
  this.waveformData = generatePlaceholderWaveform(this.options.samples);
1042
1346
  }
1043
1347
  }
@@ -1048,18 +1352,26 @@
1048
1352
  this.options.onLoad(this);
1049
1353
  }
1050
1354
  } catch (error) {
1051
- console.error("Failed to load audio:", error);
1052
1355
  this.onError(error);
1053
1356
  } finally {
1054
1357
  this.setLoading(false);
1055
1358
  }
1056
1359
  }
1057
1360
  /**
1058
- * Load a new track
1059
- * @param {string} url - Audio URL
1060
- * @param {string} [title] - Track title
1061
- * @param {string} [subtitle] - Track subtitle
1062
- * @param {Object} [options] - Additional options
1361
+ * Swap the player to a new track at runtime.
1362
+ *
1363
+ * Pauses any current playback, fully resets the audio element (self mode),
1364
+ * clears error/marker/progress state, merges the new metadata into
1365
+ * `this.options`, updates the subtitle/artwork DOM, then calls
1366
+ * {@link WaveformPlayer#load}. Auto-plays the new track unless
1367
+ * `options.autoplay === false`.
1368
+ * @param {string} url - Audio URL.
1369
+ * @param {string|null} [title=null] - Track title; keeps the existing
1370
+ * title when null.
1371
+ * @param {string|null} [subtitle=null] - Track subtitle; pass `''` to hide
1372
+ * the subtitle row, or null to keep the existing one.
1373
+ * @param {Object} [options={}] - Additional options to merge (e.g.
1374
+ * `preload`, `artwork`, `markers`, `autoplay`).
1063
1375
  * @returns {Promise<void>}
1064
1376
  */
1065
1377
  async loadTrack(url, title = null, subtitle = null, options = {}) {
@@ -1104,14 +1416,24 @@
1104
1416
  }
1105
1417
  this.options.markers = options.markers || [];
1106
1418
  await this.load(url);
1107
- this.play().catch(() => {
1108
- });
1419
+ if (options.autoplay !== false) {
1420
+ this.play()?.catch(() => {
1421
+ });
1422
+ }
1109
1423
  }
1110
1424
  // ============================================
1111
1425
  // Visualization
1112
1426
  // ============================================
1113
1427
  /**
1114
- * Set waveform data
1428
+ * Normalise externally-supplied waveform data into `this.waveformData` and
1429
+ * redraw.
1430
+ *
1431
+ * Accepts several shapes: a `.json` URL (fetched async; peaks and any
1432
+ * embedded `markers` are applied on resolve), a JSON-encoded array string,
1433
+ * a comma-separated number string, or a plain number array. Malformed
1434
+ * input degrades to an empty array rather than throwing.
1435
+ * @param {string|number[]} data - Peaks as an array, a JSON/CSV string, or
1436
+ * a URL to a `.json` peaks file.
1115
1437
  * @private
1116
1438
  */
1117
1439
  setWaveformData(data) {
@@ -1140,7 +1462,9 @@
1140
1462
  this.drawWaveform();
1141
1463
  }
1142
1464
  /**
1143
- * Draw waveform
1465
+ * Render the current waveform + progress to the canvas via the shared
1466
+ * {@link draw} routine, passing the resolved style and colours. No-op
1467
+ * before the context exists or while there is no peak data.
1144
1468
  * @private
1145
1469
  */
1146
1470
  drawWaveform() {
@@ -1153,7 +1477,9 @@
1153
1477
  });
1154
1478
  }
1155
1479
  /**
1156
- * Resize canvas
1480
+ * Re-fit the canvas backing store to its parent's width and the configured
1481
+ * height, scaled by the device pixel ratio for crisp rendering, then
1482
+ * redraw. Guards against running after destruction.
1157
1483
  * @private
1158
1484
  */
1159
1485
  resizeCanvas() {
@@ -1168,22 +1494,31 @@
1168
1494
  this.drawWaveform();
1169
1495
  }
1170
1496
  /**
1171
- * Render markers on the waveform
1497
+ * Render the configured cue markers as positioned, clickable buttons over
1498
+ * the waveform.
1499
+ *
1500
+ * Clears any existing markers first, then bails out unless `showMarkers` is
1501
+ * on, markers exist, and a duration is known (via the mode-agnostic
1502
+ * {@link WaveformPlayer#getSeekDuration}). Each marker is placed by its
1503
+ * time-as-percentage, carries a tooltip and ARIA label, and seeks on click
1504
+ * (also starting playback when `playOnSeek` is set and currently paused).
1505
+ * Markers past the track duration are skipped with a warning.
1172
1506
  * @private
1173
1507
  */
1174
1508
  renderMarkers() {
1175
1509
  if (!this.markersContainer) return;
1176
1510
  this.markersContainer.innerHTML = "";
1177
1511
  if (!this.options.showMarkers || !this.options.markers?.length) return;
1178
- if (!this.audio || !this.audio.duration || this.audio.duration === 0) {
1512
+ const duration = this.getSeekDuration();
1513
+ if (!duration) {
1179
1514
  return;
1180
1515
  }
1181
1516
  this.options.markers.forEach((marker, index) => {
1182
- if (marker.time > this.audio.duration) {
1183
- console.warn(`Marker "${marker.label}" at ${marker.time}s exceeds audio duration of ${this.audio.duration}s`);
1517
+ if (marker.time > duration) {
1518
+ console.warn(`[WaveformPlayer] Marker "${marker.label}" at ${marker.time}s exceeds audio duration of ${duration}s`);
1184
1519
  return;
1185
1520
  }
1186
- const position = marker.time / this.audio.duration * 100;
1521
+ const position = marker.time / duration * 100;
1187
1522
  const markerEl = document.createElement("button");
1188
1523
  markerEl.className = "waveform-marker";
1189
1524
  markerEl.style.left = `${position}%`;
@@ -1204,35 +1539,49 @@
1204
1539
  this.markersContainer.appendChild(markerEl);
1205
1540
  });
1206
1541
  }
1542
+ /**
1543
+ * Highlight the marker at `index` (toggling an `active` class) and clear
1544
+ * the rest. Pass `null` to clear all. Lets an external controller (e.g. a
1545
+ * DJ bar) reflect the current section without reaching into the player's
1546
+ * private marker DOM.
1547
+ * @param {number|null} index - Marker index to activate, or `null` to clear.
1548
+ */
1549
+ setActiveMarker(index) {
1550
+ if (!this.markersContainer) return;
1551
+ const markers = this.markersContainer.querySelectorAll(".waveform-marker");
1552
+ markers.forEach((el, i) => el.classList.toggle("active", i === index));
1553
+ }
1207
1554
  // ============================================
1208
1555
  // Event Handlers
1209
1556
  // ============================================
1210
1557
  /**
1211
- * Handle canvas click
1558
+ * Seek to the clicked horizontal position on the waveform canvas.
1559
+ *
1560
+ * Converts the click X into a 0..1 percentage. In external mode it
1561
+ * dispatches a cancelable `waveformplayer:request-seek` event (updating the
1562
+ * local visual optimistically unless the controller vetoes it); in self
1563
+ * mode it seeks the owned `<audio>` via
1564
+ * {@link WaveformPlayer#seekToPercent}.
1565
+ * @param {MouseEvent} event - The canvas click event.
1212
1566
  * @private
1567
+ * @fires WaveformPlayer#waveformplayer:request-seek
1213
1568
  */
1214
1569
  handleCanvasClick(event) {
1215
1570
  const rect = this.canvas.getBoundingClientRect();
1216
1571
  const x = event.clientX - rect.left;
1217
- const targetPercent = Math.max(0, Math.min(1, x / rect.width));
1572
+ const targetPercent = clamp(x / rect.width);
1218
1573
  if (this.options.audioMode === "external") {
1219
- const evt = new CustomEvent("waveformplayer:request-seek", {
1220
- bubbles: true,
1221
- cancelable: true,
1222
- detail: { ...this._buildTrackDetail(), percent: targetPercent }
1223
- });
1224
- this.container.dispatchEvent(evt);
1225
- if (!evt.defaultPrevented) {
1226
- this.progress = targetPercent;
1227
- this.drawWaveform?.();
1228
- }
1574
+ this._requestSeek(targetPercent);
1229
1575
  return;
1230
1576
  }
1231
1577
  if (!this.audio || !this.audio.duration) return;
1232
1578
  this.seekToPercent(targetPercent);
1233
1579
  }
1234
1580
  /**
1235
- * Set loading state
1581
+ * Toggle the loading state: show/hide the spinner overlay and set
1582
+ * `aria-busy` on the accessible seek slider so assistive tech knows the
1583
+ * player is fetching/decoding.
1584
+ * @param {boolean} loading - True while audio is loading.
1236
1585
  * @private
1237
1586
  */
1238
1587
  setLoading(loading) {
@@ -1240,9 +1589,14 @@
1240
1589
  if (this.loadingEl) {
1241
1590
  this.loadingEl.style.display = loading ? "block" : "none";
1242
1591
  }
1592
+ if (this.seekEl) {
1593
+ this.seekEl.setAttribute("aria-busy", loading ? "true" : "false");
1594
+ }
1243
1595
  }
1244
1596
  /**
1245
- * Handle metadata loaded
1597
+ * `loadedmetadata` handler (self mode): write the total-time display, now
1598
+ * that duration is known re-render markers, and publish duration to the
1599
+ * accessible seek slider. No-op during destruction.
1246
1600
  * @private
1247
1601
  */
1248
1602
  onMetadataLoaded() {
@@ -1251,81 +1605,94 @@
1251
1605
  this.totalTimeEl.textContent = formatTime(this.audio.duration);
1252
1606
  }
1253
1607
  this.renderMarkers();
1608
+ this.updateSeekAccessibility();
1254
1609
  }
1255
1610
  /**
1256
- * Handle play event
1611
+ * Reflect play/pause state on the transport button: toggle the `playing`
1612
+ * class and swap the play/pause icon visibility. The single source of
1613
+ * truth shared by `onPlay`, `onPause`, and the external-mode
1614
+ * `setPlayingState` pump so they can't drift. No-op without a button.
1615
+ * @param {boolean} isPlaying - Whether playback is active.
1257
1616
  * @private
1258
1617
  */
1618
+ setPlayButtonState(isPlaying) {
1619
+ if (!this.playBtn) return;
1620
+ this.playBtn.classList.toggle("playing", isPlaying);
1621
+ const playIcon = this.playBtn.querySelector(".waveform-icon-play");
1622
+ const pauseIcon = this.playBtn.querySelector(".waveform-icon-pause");
1623
+ if (playIcon) playIcon.style.display = isPlaying ? "none" : "flex";
1624
+ if (pauseIcon) pauseIcon.style.display = isPlaying ? "flex" : "none";
1625
+ }
1626
+ /**
1627
+ * `play` handler (self mode): set the playing flag, swap the button to its
1628
+ * pause icon, start the smooth progress loop, dispatch
1629
+ * `waveformplayer:play`, and fire the `onPlay` callback. No-op during
1630
+ * destruction.
1631
+ * @private
1632
+ * @fires WaveformPlayer#waveformplayer:play
1633
+ */
1259
1634
  onPlay() {
1260
1635
  if (this.isDestroying) return;
1261
1636
  this.isPlaying = true;
1262
- if (this.playBtn) {
1263
- this.playBtn.classList.add("playing");
1264
- const playIcon = this.playBtn.querySelector(".waveform-icon-play");
1265
- const pauseIcon = this.playBtn.querySelector(".waveform-icon-pause");
1266
- if (playIcon) playIcon.style.display = "none";
1267
- if (pauseIcon) pauseIcon.style.display = "flex";
1268
- }
1637
+ this.setPlayButtonState(true);
1269
1638
  this.startSmoothUpdate();
1270
- this.container.dispatchEvent(new CustomEvent("waveformplayer:play", {
1271
- bubbles: true,
1272
- detail: { player: this, url: this.options.url }
1273
- }));
1639
+ this._emit("waveformplayer:play", { player: this, url: this.options.url });
1274
1640
  if (this.options.onPlay) {
1275
1641
  this.options.onPlay(this);
1276
1642
  }
1277
1643
  }
1278
1644
  /**
1279
- * Handle pause event
1645
+ * `pause` handler (self mode): clear the playing flag, swap the button back
1646
+ * to its play icon, stop the smooth progress loop, dispatch
1647
+ * `waveformplayer:pause`, and fire the `onPause` callback. No-op during
1648
+ * destruction.
1280
1649
  * @private
1650
+ * @fires WaveformPlayer#waveformplayer:pause
1281
1651
  */
1282
1652
  onPause() {
1283
1653
  if (this.isDestroying) return;
1284
1654
  this.isPlaying = false;
1285
- if (this.playBtn) {
1286
- this.playBtn.classList.remove("playing");
1287
- const playIcon = this.playBtn.querySelector(".waveform-icon-play");
1288
- const pauseIcon = this.playBtn.querySelector(".waveform-icon-pause");
1289
- if (playIcon) playIcon.style.display = "flex";
1290
- if (pauseIcon) pauseIcon.style.display = "none";
1291
- }
1655
+ this.setPlayButtonState(false);
1292
1656
  this.stopSmoothUpdate();
1293
- this.container.dispatchEvent(new CustomEvent("waveformplayer:pause", {
1294
- bubbles: true,
1295
- detail: { player: this, url: this.options.url }
1296
- }));
1657
+ this._emit("waveformplayer:pause", { player: this, url: this.options.url });
1297
1658
  if (this.options.onPause) {
1298
1659
  this.options.onPause(this);
1299
1660
  }
1300
1661
  }
1301
1662
  /**
1302
- * Handle ended event
1663
+ * `ended` handler (self mode): reset progress and `currentTime` to the
1664
+ * start, redraw, reset the time display, dispatch `waveformplayer:ended`
1665
+ * (carrying the final time), run {@link WaveformPlayer#onPause}, and fire
1666
+ * the `onEnd` callback. No-op during destruction.
1303
1667
  * @private
1668
+ * @fires WaveformPlayer#waveformplayer:ended
1304
1669
  */
1305
1670
  onEnded() {
1306
1671
  if (this.isDestroying) return;
1672
+ const duration = this.audio.duration;
1307
1673
  this.progress = 0;
1308
1674
  this.audio.currentTime = 0;
1309
1675
  this.drawWaveform();
1310
1676
  if (this.currentTimeEl) {
1311
1677
  this.currentTimeEl.textContent = "0:00";
1312
1678
  }
1313
- this.container.dispatchEvent(new CustomEvent("waveformplayer:ended", {
1314
- bubbles: true,
1315
- detail: { player: this, url: this.options.url }
1316
- }));
1679
+ this._emit("waveformplayer:ended", { player: this, url: this.options.url, currentTime: duration, duration });
1317
1680
  this.onPause();
1318
1681
  if (this.options.onEnd) {
1319
1682
  this.options.onEnd(this);
1320
1683
  }
1321
1684
  }
1322
1685
  /**
1323
- * Handle error event
1686
+ * `error` handler: set the error flag, hide the spinner, reveal the error
1687
+ * overlay, dim the canvas, disable the play button, and fire the `onError`
1688
+ * callback. No-op during destruction.
1689
+ * @param {Event|Error} error - The audio error event, or an Error thrown
1690
+ * during loading.
1324
1691
  * @private
1325
1692
  */
1326
1693
  onError(error) {
1327
1694
  if (this.isDestroying) return;
1328
- console.error("Audio error:", error);
1695
+ console.error("[WaveformPlayer] Audio error:", error);
1329
1696
  this.hasError = true;
1330
1697
  this.setLoading(false);
1331
1698
  if (this.errorEl) {
@@ -1345,7 +1712,10 @@
1345
1712
  // Progress Updates
1346
1713
  // ============================================
1347
1714
  /**
1348
- * Start smooth update animation
1715
+ * Start the `requestAnimationFrame` loop that drives smooth progress
1716
+ * updates while playing (self mode only — external mode is redrawn by
1717
+ * controller {@link WaveformPlayer#setProgress} pushes). Cancels any
1718
+ * existing loop first so it's safe to call repeatedly.
1349
1719
  * @private
1350
1720
  */
1351
1721
  startSmoothUpdate() {
@@ -1359,7 +1729,7 @@
1359
1729
  this.updateTimer = requestAnimationFrame(update);
1360
1730
  }
1361
1731
  /**
1362
- * Stop smooth update animation
1732
+ * Cancel the smooth-update animation frame, if one is scheduled.
1363
1733
  * @private
1364
1734
  */
1365
1735
  stopSmoothUpdate() {
@@ -1369,8 +1739,15 @@
1369
1739
  }
1370
1740
  }
1371
1741
  /**
1372
- * Update progress
1742
+ * Recompute progress from the owned `<audio>` clock and reflect it
1743
+ * everywhere (self mode only — external mode uses
1744
+ * {@link WaveformPlayer#setProgress}).
1745
+ *
1746
+ * Redraws the canvas when progress moves meaningfully, updates the
1747
+ * current-time display, dispatches `waveformplayer:timeupdate`, fires the
1748
+ * `onTimeUpdate` callback, and refreshes the accessible slider values.
1373
1749
  * @private
1750
+ * @fires WaveformPlayer#waveformplayer:timeupdate
1374
1751
  */
1375
1752
  updateProgress() {
1376
1753
  if (!this.audio || !this.audio.duration) return;
@@ -1382,24 +1759,23 @@
1382
1759
  if (this.currentTimeEl) {
1383
1760
  this.currentTimeEl.textContent = formatTime(this.audio.currentTime);
1384
1761
  }
1385
- this.container.dispatchEvent(new CustomEvent("waveformplayer:timeupdate", {
1386
- bubbles: true,
1387
- detail: {
1388
- player: this,
1389
- currentTime: this.audio.currentTime,
1390
- duration: this.audio.duration,
1391
- url: this.options.url
1392
- }
1393
- }));
1762
+ this._emit("waveformplayer:timeupdate", {
1763
+ player: this,
1764
+ currentTime: this.audio.currentTime,
1765
+ duration: this.audio.duration,
1766
+ progress: this.progress,
1767
+ url: this.options.url
1768
+ });
1394
1769
  if (this.options.onTimeUpdate) {
1395
1770
  this.options.onTimeUpdate(this.audio.currentTime, this.audio.duration, this);
1396
1771
  }
1772
+ this.updateSeekAccessibility();
1397
1773
  }
1398
1774
  // ============================================
1399
1775
  // UI Updates
1400
1776
  // ============================================
1401
1777
  /**
1402
- * Update BPM display
1778
+ * Show the detected BPM in the badge, once a value has been detected.
1403
1779
  * @private
1404
1780
  */
1405
1781
  updateBPMDisplay() {
@@ -1409,10 +1785,14 @@
1409
1785
  }
1410
1786
  }
1411
1787
  /**
1412
- * Update speed UI to reflect current rate
1788
+ * Sync the speed control's label and the menu's active-option highlight to
1789
+ * the audio element's current `playbackRate`. No-op in external mode (no
1790
+ * owned `<audio>`), which also avoids reading `playbackRate` before the
1791
+ * element exists.
1413
1792
  * @private
1414
1793
  */
1415
1794
  updateSpeedUI() {
1795
+ if (!this.audio) return;
1416
1796
  const speedValue = this.container.querySelector(".speed-value");
1417
1797
  if (speedValue) {
1418
1798
  const rate = this.audio.playbackRate;
@@ -1439,19 +1819,19 @@
1439
1819
  * setPlayingState() / setProgress(). Calling preventDefault() on
1440
1820
  * the event lets the controller veto the play (state is unchanged).
1441
1821
  *
1442
- * @return {Promise|undefined}
1822
+ * When `singlePlay` is enabled, any other currently-playing instance is
1823
+ * paused first.
1824
+ *
1825
+ * @return {Promise|undefined} The promise from `HTMLMediaElement.play()` in
1826
+ * self mode; `undefined` in external mode.
1827
+ * @fires WaveformPlayer#waveformplayer:request-play
1443
1828
  */
1444
1829
  play() {
1445
1830
  if (this.options.singlePlay && _WaveformPlayer.currentlyPlaying && _WaveformPlayer.currentlyPlaying !== this) {
1446
1831
  _WaveformPlayer.currentlyPlaying.pause();
1447
1832
  }
1448
1833
  if (this.options.audioMode === "external") {
1449
- const evt = new CustomEvent("waveformplayer:request-play", {
1450
- bubbles: true,
1451
- cancelable: true,
1452
- detail: this._buildTrackDetail()
1453
- });
1454
- this.container.dispatchEvent(evt);
1834
+ const evt = this._emit("waveformplayer:request-play", this._buildTrackDetail(), true);
1455
1835
  if (!evt.defaultPrevented) {
1456
1836
  _WaveformPlayer.currentlyPlaying = this;
1457
1837
  }
@@ -1465,17 +1845,15 @@
1465
1845
  *
1466
1846
  * In `audioMode: 'external'`, dispatches `waveformplayer:request-pause`
1467
1847
  * (cancelable) and does NOT touch any audio element. See play().
1848
+ *
1849
+ * @fires WaveformPlayer#waveformplayer:request-pause
1468
1850
  */
1469
1851
  pause() {
1470
1852
  if (_WaveformPlayer.currentlyPlaying === this) {
1471
1853
  _WaveformPlayer.currentlyPlaying = null;
1472
1854
  }
1473
1855
  if (this.options.audioMode === "external") {
1474
- this.container.dispatchEvent(new CustomEvent("waveformplayer:request-pause", {
1475
- bubbles: true,
1476
- cancelable: true,
1477
- detail: this._buildTrackDetail()
1478
- }));
1856
+ this._emit("waveformplayer:request-pause", this._buildTrackDetail(), true);
1479
1857
  return;
1480
1858
  }
1481
1859
  this.audio.pause();
@@ -1494,8 +1872,12 @@
1494
1872
  url: this.options.url,
1495
1873
  title: this.options.title,
1496
1874
  subtitle: this.options.subtitle,
1497
- artist: this.options.artist,
1875
+ // Core has no separate `artist` option; mirror subtitle so the
1876
+ // published event detail is self-consistent for controllers.
1877
+ artist: this.options.artist || this.options.subtitle,
1498
1878
  artwork: this.options.artwork,
1879
+ markers: this.options.markers,
1880
+ waveform: this.options.waveform,
1499
1881
  id: this.id,
1500
1882
  player: this
1501
1883
  };
@@ -1505,31 +1887,26 @@
1505
1887
  * touching audio. Mirrors what onPlay()/onPause() do but skips the
1506
1888
  * audio-element interactions. Safe to call repeatedly — idempotent.
1507
1889
  *
1508
- * @param {boolean} playing
1890
+ * Only dispatches `waveformplayer:play`/`waveformplayer:pause` (and runs
1891
+ * the matching callback) on an actual transition, starting/stopping the
1892
+ * smooth-update loop accordingly.
1893
+ *
1894
+ * @param {boolean} playing - True to enter the playing state, false to
1895
+ * enter the paused state.
1896
+ * @fires WaveformPlayer#waveformplayer:play
1897
+ * @fires WaveformPlayer#waveformplayer:pause
1509
1898
  */
1510
1899
  setPlayingState(playing) {
1511
1900
  const wasPlaying = this.isPlaying;
1512
1901
  this.isPlaying = !!playing;
1513
- if (this.playBtn) {
1514
- this.playBtn.classList.toggle("playing", this.isPlaying);
1515
- const playIcon = this.playBtn.querySelector(".waveform-icon-play");
1516
- const pauseIcon = this.playBtn.querySelector(".waveform-icon-pause");
1517
- if (playIcon) playIcon.style.display = this.isPlaying ? "none" : "flex";
1518
- if (pauseIcon) pauseIcon.style.display = this.isPlaying ? "flex" : "none";
1519
- }
1902
+ this.setPlayButtonState(this.isPlaying);
1520
1903
  if (this.isPlaying && !wasPlaying) {
1521
1904
  this.startSmoothUpdate?.();
1522
- this.container.dispatchEvent(new CustomEvent("waveformplayer:play", {
1523
- bubbles: true,
1524
- detail: { player: this, url: this.options.url }
1525
- }));
1905
+ this._emit("waveformplayer:play", { player: this, url: this.options.url });
1526
1906
  if (this.options.onPlay) this.options.onPlay(this);
1527
1907
  } else if (!this.isPlaying && wasPlaying) {
1528
1908
  this.stopSmoothUpdate?.();
1529
- this.container.dispatchEvent(new CustomEvent("waveformplayer:pause", {
1530
- bubbles: true,
1531
- detail: { player: this, url: this.options.url }
1532
- }));
1909
+ this._emit("waveformplayer:pause", { player: this, url: this.options.url });
1533
1910
  if (this.options.onPause) this.options.onPause(this);
1534
1911
  }
1535
1912
  }
@@ -1538,27 +1915,45 @@
1538
1915
  * from an external clock (e.g. WaveformBar's audio element's
1539
1916
  * timeupdate). Drives the canvas redraw + the time displays.
1540
1917
  *
1541
- * @param {number} currentTime Current playback position in seconds.
1542
- * @param {number} duration Total track duration in seconds.
1918
+ * Redraws the canvas, updates the current/total time displays, stores the
1919
+ * external duration for the accessible slider, dispatches
1920
+ * `waveformplayer:timeupdate`, runs `onTimeUpdate`, and synthesizes a
1921
+ * one-shot `waveformplayer:ended` (with `onEnd`) when progress reaches the
1922
+ * end. No-op for a non-positive duration.
1923
+ *
1924
+ * @param {number} currentTime - Current playback position in seconds.
1925
+ * @param {number} duration - Total track duration in seconds.
1926
+ * @fires WaveformPlayer#waveformplayer:timeupdate
1927
+ * @fires WaveformPlayer#waveformplayer:ended
1543
1928
  */
1544
1929
  setProgress(currentTime, duration) {
1545
1930
  if (!duration || duration <= 0) return;
1546
- this.progress = Math.max(0, Math.min(1, currentTime / duration));
1931
+ this.progress = clamp(currentTime / duration);
1547
1932
  if (this.currentTimeEl) this.currentTimeEl.textContent = formatTime(currentTime);
1548
- if (this.totalTimeEl && (!this.totalTimeEl.dataset._extSet || this._extDuration !== duration)) {
1933
+ this._extDuration = duration;
1934
+ if (this.totalTimeEl && (!this.totalTimeEl.dataset._extSet || this.totalTimeEl.dataset._extDur !== String(duration))) {
1549
1935
  this.totalTimeEl.textContent = formatTime(duration);
1550
1936
  this.totalTimeEl.dataset._extSet = "1";
1551
- this._extDuration = duration;
1937
+ this.totalTimeEl.dataset._extDur = String(duration);
1552
1938
  }
1553
1939
  this.drawWaveform?.();
1554
- this.container.dispatchEvent(new CustomEvent("waveformplayer:timeupdate", {
1555
- bubbles: true,
1556
- detail: { player: this, currentTime, duration, progress: this.progress }
1557
- }));
1558
- if (this.options.onTimeUpdate) this.options.onTimeUpdate(this, currentTime, duration);
1940
+ this._emit("waveformplayer:timeupdate", { player: this, currentTime, duration, progress: this.progress, url: this.options.url });
1941
+ if (this.options.onTimeUpdate) this.options.onTimeUpdate(currentTime, duration, this);
1942
+ if (this.progress >= 1) {
1943
+ if (!this._extEnded) {
1944
+ this._extEnded = true;
1945
+ this._emit("waveformplayer:ended", { player: this, url: this.options.url, currentTime: duration, duration });
1946
+ if (this.options.onEnd) this.options.onEnd(this);
1947
+ }
1948
+ } else {
1949
+ this._extEnded = false;
1950
+ }
1951
+ this.updateSeekAccessibility();
1559
1952
  }
1560
1953
  /**
1561
- * Toggle play/pause
1954
+ * Toggle between play and pause based on the current `isPlaying` state.
1955
+ * Works in both audio modes (in external mode it routes through the
1956
+ * request-play/pause events).
1562
1957
  */
1563
1958
  togglePlay() {
1564
1959
  if (this.isPlaying) {
@@ -1568,52 +1963,71 @@
1568
1963
  }
1569
1964
  }
1570
1965
  /**
1571
- * Seek to time in seconds
1572
- * @param {number} seconds - Time in seconds
1966
+ * Seek the owned `<audio>` element to an absolute time, clamped to
1967
+ * `[0, duration]`, and refresh progress. Self mode only — a no-op when
1968
+ * there is no audio element or duration. External-mode keyboard/click
1969
+ * seeks go through {@link WaveformPlayer#seekToSeconds} instead.
1970
+ * @param {number} seconds - Target time in seconds.
1573
1971
  */
1574
1972
  seekTo(seconds) {
1575
1973
  if (this.audio && this.audio.duration) {
1576
- this.audio.currentTime = Math.max(0, Math.min(seconds, this.audio.duration));
1974
+ this.audio.currentTime = clamp(seconds, 0, this.audio.duration);
1577
1975
  this.updateProgress();
1578
1976
  }
1579
1977
  }
1580
1978
  /**
1581
- * Seek to percentage
1582
- * @param {number} percent - Percentage (0-1)
1979
+ * Seek the owned `<audio>` element to a fraction of the track, clamped to
1980
+ * `[0, 1]`, and refresh progress. Self mode only — a no-op without an audio
1981
+ * element or duration.
1982
+ * @param {number} percent - Position as a fraction from 0 to 1.
1583
1983
  */
1584
1984
  seekToPercent(percent) {
1585
1985
  if (this.audio && this.audio.duration) {
1586
- this.audio.currentTime = this.audio.duration * Math.max(0, Math.min(1, percent));
1986
+ this.audio.currentTime = this.audio.duration * clamp(percent);
1587
1987
  this.updateProgress();
1588
1988
  }
1589
1989
  }
1590
1990
  /**
1591
- * Set volume
1592
- * @param {number} volume - Volume (0-1)
1991
+ * Set the owned `<audio>` element's volume, clamped to `[0, 1]`. Self mode
1992
+ * only a no-op in external mode where the controller owns volume.
1993
+ * @param {number} volume - Volume from 0 (silent) to 1 (full).
1593
1994
  */
1594
1995
  setVolume(volume) {
1595
- if (this.audio) {
1596
- this.audio.volume = Math.max(0, Math.min(1, volume));
1996
+ const v = Number(volume);
1997
+ if (this.audio && Number.isFinite(v)) {
1998
+ this.audio.volume = clamp(v);
1597
1999
  }
1598
2000
  }
1599
2001
  /**
1600
- * Set playback rate
1601
- * @param {number} rate - Playback rate (0.5 to 2)
2002
+ * Set the owned `<audio>` element's playback rate (clamped to 0.5–2),
2003
+ * persist it onto `this.options.playbackRate`, and refresh the speed UI.
2004
+ * Self mode only — a no-op in external mode.
2005
+ * @param {number} rate - Desired playback rate; clamped to the 0.5–2 range.
1602
2006
  */
1603
2007
  setPlaybackRate(rate) {
1604
2008
  if (!this.audio) return;
1605
- const clampedRate = Math.max(0.5, Math.min(2, rate));
2009
+ const clampedRate = clamp(rate, 0.5, 2);
1606
2010
  this.audio.playbackRate = clampedRate;
1607
2011
  this.options.playbackRate = clampedRate;
1608
2012
  this.updateSpeedUI();
1609
2013
  }
1610
2014
  /**
1611
- * Destroy player instance
2015
+ * Tear down the player and release all resources.
2016
+ *
2017
+ * Flags destruction (so in-flight handlers bail), dispatches
2018
+ * `waveformplayer:destroy`, stops playback and the animation loop, aborts
2019
+ * every listener registered on the instance signal, disconnects the resize
2020
+ * observer, removes the window-resize handler, drops the instance from the
2021
+ * static map and `currentlyPlaying`, resets/releases the audio element, and
2022
+ * empties the container.
2023
+ * @fires WaveformPlayer#waveformplayer:destroy
1612
2024
  */
1613
2025
  destroy() {
1614
2026
  this.isDestroying = true;
2027
+ this._emit("waveformplayer:destroy", { player: this, url: this.options.url });
1615
2028
  this.pause();
1616
2029
  this.stopSmoothUpdate();
2030
+ this._ac?.abort();
1617
2031
  if (this.resizeObserver) {
1618
2032
  this.resizeObserver.disconnect();
1619
2033
  this.resizeObserver = null;
@@ -1686,7 +2100,7 @@
1686
2100
  const result = await generateWaveform(url, samples);
1687
2101
  return result.peaks;
1688
2102
  } catch (error) {
1689
- console.error("Failed to generate waveform:", error);
2103
+ console.error("[WaveformPlayer] Failed to generate waveform:", error);
1690
2104
  throw error;
1691
2105
  }
1692
2106
  }
@@ -1739,8 +2153,10 @@
1739
2153
  };
1740
2154
 
1741
2155
  // src/js/index.js
2156
+ WaveformPlayer.utils = { formatTime, extractTitleFromUrl, escapeHtml, isSafeHref };
2157
+ var isBrowser = () => typeof window !== "undefined" && typeof document !== "undefined";
1742
2158
  function autoInit() {
1743
- if (typeof document === "undefined") return;
2159
+ if (!isBrowser()) return;
1744
2160
  const elements = document.querySelectorAll("[data-waveform-player]");
1745
2161
  elements.forEach((element) => {
1746
2162
  if (element.dataset.waveformInitialized === "true") return;
@@ -1748,11 +2164,11 @@
1748
2164
  new WaveformPlayer(element);
1749
2165
  element.dataset.waveformInitialized = "true";
1750
2166
  } catch (error) {
1751
- console.error("Failed to initialize WaveformPlayer:", error, element);
2167
+ console.error("[WaveformPlayer] Failed to initialize:", error, element);
1752
2168
  }
1753
2169
  });
1754
2170
  }
1755
- if (typeof document !== "undefined") {
2171
+ if (isBrowser()) {
1756
2172
  if (document.readyState === "loading") {
1757
2173
  document.addEventListener("DOMContentLoaded", autoInit);
1758
2174
  } else {
@@ -1760,7 +2176,7 @@
1760
2176
  }
1761
2177
  }
1762
2178
  WaveformPlayer.init = autoInit;
1763
- if (typeof window !== "undefined") {
2179
+ if (isBrowser()) {
1764
2180
  window.WaveformPlayer = WaveformPlayer;
1765
2181
  }
1766
2182
  var index_default = WaveformPlayer;