@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.
@@ -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)
@@ -628,6 +712,8 @@
628
712
  subtitle: null,
629
713
  artwork: null,
630
714
  album: "",
715
+ // Message shown in the error state when audio fails to load.
716
+ errorText: "Unable to load audio",
631
717
  // Icons (SVG)
632
718
  playIcon: '<svg viewBox="0 0 24 24" width="16" height="16"><path d="M8 5v14l11-7z"/></svg>',
633
719
  pauseIcon: '<svg viewBox="0 0 24 24" width="16" height="16"><path d="M6 4h4v16H6zM14 4h4v16h-4z"/></svg>',
@@ -657,17 +743,32 @@
657
743
  /** @type {WaveformPlayer|null} */
658
744
  static currentlyPlaying = null;
659
745
  /**
660
- * Create a new WaveformPlayer instance
661
- * @param {string|HTMLElement} container - Container element or selector
662
- * @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
663
761
  */
664
762
  constructor(container, options = {}) {
665
763
  this.container = typeof container === "string" ? document.querySelector(container) : container;
666
764
  if (!this.container) {
667
- throw new Error("WaveformPlayer: Container element not found");
765
+ throw new Error("[WaveformPlayer] Container element not found");
668
766
  }
669
767
  const dataOptions = parseDataAttributes(this.container);
670
- 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);
671
772
  const preset = getColorPreset(this.options.colorPreset);
672
773
  for (const [key, value] of Object.entries(preset)) {
673
774
  if (this.options[key] === null || this.options[key] === void 0) {
@@ -693,21 +794,54 @@
693
794
  this.hasError = false;
694
795
  this.updateTimer = null;
695
796
  this.resizeObserver = null;
797
+ this._ac = new AbortController();
696
798
  this.id = this.container.id || generateId(this.options.url);
697
799
  _WaveformPlayer.instances.set(this.id, this);
698
800
  this.init();
699
801
  setTimeout(() => {
700
- this.container.dispatchEvent(new CustomEvent("waveformplayer:ready", {
701
- bubbles: true,
702
- detail: { player: this, url: this.options.url }
703
- }));
802
+ this._emit("waveformplayer:ready", { player: this, url: this.options.url });
704
803
  }, 100);
705
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
+ }
706
837
  // ============================================
707
838
  // Initialization
708
839
  // ============================================
709
840
  /**
710
- * 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.
711
845
  * @private
712
846
  */
713
847
  init() {
@@ -723,16 +857,24 @@
723
857
  if (this.options.url) {
724
858
  this.load(this.options.url).then(() => {
725
859
  if (this.options.autoplay) {
726
- this.play();
860
+ this.play()?.catch(() => {
861
+ });
727
862
  }
728
863
  }).catch((error) => {
729
- console.error("Failed to load audio:", error);
864
+ console.error("[WaveformPlayer] Failed to load audio:", error);
730
865
  });
731
866
  }
732
867
  });
733
868
  }
734
869
  /**
735
- * 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.
736
878
  * @private
737
879
  */
738
880
  createDOM() {
@@ -807,8 +949,8 @@
807
949
  <canvas></canvas>
808
950
  <div class="waveform-markers"></div>
809
951
  <div class="waveform-loading" style="display:none;"></div>
810
- <div class="waveform-error" style="display:none;">
811
- <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>
812
954
  </div>
813
955
  </div>
814
956
  </div>
@@ -857,7 +999,9 @@
857
999
  // Feature Initialization
858
1000
  // ============================================
859
1001
  /**
860
- * 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}.
861
1005
  * @private
862
1006
  */
863
1007
  initPlaybackSpeed() {
@@ -869,7 +1013,11 @@
869
1013
  }
870
1014
  }
871
1015
  /**
872
- * 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.
873
1021
  * @private
874
1022
  */
875
1023
  initSpeedControls() {
@@ -879,10 +1027,10 @@
879
1027
  speedBtn.addEventListener("click", (e) => {
880
1028
  e.stopPropagation();
881
1029
  speedMenu.style.display = speedMenu.style.display === "none" ? "block" : "none";
882
- });
1030
+ }, { signal: this._ac.signal });
883
1031
  document.addEventListener("click", () => {
884
1032
  speedMenu.style.display = "none";
885
- });
1033
+ }, { signal: this._ac.signal });
886
1034
  speedMenu.addEventListener("click", (e) => {
887
1035
  e.stopPropagation();
888
1036
  if (e.target.classList.contains("speed-option")) {
@@ -890,11 +1038,18 @@
890
1038
  this.setPlaybackRate(rate);
891
1039
  speedMenu.style.display = "none";
892
1040
  }
893
- });
1041
+ }, { signal: this._ac.signal });
894
1042
  this.updateSpeedUI();
895
1043
  }
896
1044
  /**
897
- * 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.
898
1053
  * @private
899
1054
  */
900
1055
  initKeyboardControls() {
@@ -907,7 +1062,7 @@
907
1062
  });
908
1063
  this.container.setAttribute("tabindex", "0");
909
1064
  this.container.focus();
910
- });
1065
+ }, { signal: this._ac.signal });
911
1066
  this.container.addEventListener("keydown", (e) => {
912
1067
  if (document.activeElement !== this.container) return;
913
1068
  const key = e.key;
@@ -922,17 +1077,17 @@
922
1077
  " ": () => this.togglePlay()
923
1078
  };
924
1079
  if (hasAudio) {
925
- actions["ArrowLeft"] = () => this.seekTo(Math.max(0, currentTime - 5));
926
- actions["ArrowRight"] = () => this.seekTo(Math.min(this.audio.duration, currentTime + 5));
927
- actions["ArrowUp"] = () => this.setVolume(Math.min(1, this.audio.volume + 0.1));
928
- 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));
929
1084
  actions["m"] = actions["M"] = () => this.audio.muted = !this.audio.muted;
930
1085
  }
931
1086
  if (actions[key]) {
932
1087
  e.preventDefault();
933
1088
  actions[key]();
934
1089
  }
935
- });
1090
+ }, { signal: this._ac.signal });
936
1091
  }
937
1092
  /**
938
1093
  * Expose the waveform as an accessible, keyboard-operable slider.
@@ -984,7 +1139,7 @@
984
1139
  e.preventDefault();
985
1140
  e.stopPropagation();
986
1141
  this.seekToSeconds(target);
987
- });
1142
+ }, { signal: this._ac.signal });
988
1143
  }
989
1144
  /**
990
1145
  * Total seekable duration in seconds, regardless of audio mode.
@@ -1010,26 +1165,22 @@
1010
1165
  }
1011
1166
  /**
1012
1167
  * Seek the slider to an absolute time, clamped to the track length.
1013
- * Routes through the external controller in external mode.
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.
1014
1174
  * @param {number} seconds - Target time in seconds.
1015
1175
  * @private
1176
+ * @fires WaveformPlayer#waveformplayer:request-seek
1016
1177
  */
1017
1178
  seekToSeconds(seconds) {
1018
1179
  const duration = this.getSeekDuration();
1019
1180
  if (!duration) return;
1020
- const clamped = Math.max(0, Math.min(seconds, duration));
1181
+ const clamped = clamp(seconds, 0, duration);
1021
1182
  if (this.options.audioMode === "external") {
1022
- const percent = clamped / duration;
1023
- const evt = new CustomEvent("waveformplayer:request-seek", {
1024
- bubbles: true,
1025
- cancelable: true,
1026
- detail: { ...this._buildTrackDetail(), percent }
1027
- });
1028
- this.container.dispatchEvent(evt);
1029
- if (!evt.defaultPrevented) {
1030
- this.progress = percent;
1031
- this.drawWaveform?.();
1032
- }
1183
+ this._requestSeek(clamped / duration);
1033
1184
  this.updateSeekAccessibility();
1034
1185
  return;
1035
1186
  }
@@ -1037,7 +1188,9 @@
1037
1188
  }
1038
1189
  /**
1039
1190
  * Set the slider's accessible name from `seekLabel`, falling back to the
1040
- * track title, then a generic 'Seek'.
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.
1041
1194
  * @private
1042
1195
  */
1043
1196
  applySeekLabel(title = this.options.title) {
@@ -1078,10 +1231,10 @@
1078
1231
  navigator.mediaSession.setActionHandler("play", () => this.play());
1079
1232
  navigator.mediaSession.setActionHandler("pause", () => this.pause());
1080
1233
  navigator.mediaSession.setActionHandler("seekbackward", () => {
1081
- this.seekTo(Math.max(0, this.audio.currentTime - 10));
1234
+ this.seekTo(clamp(this.audio.currentTime - 10, 0, this.audio.duration));
1082
1235
  });
1083
1236
  navigator.mediaSession.setActionHandler("seekforward", () => {
1084
- this.seekTo(Math.min(this.audio.duration, this.audio.currentTime + 10));
1237
+ this.seekTo(clamp(this.audio.currentTime + 10, 0, this.audio.duration));
1085
1238
  });
1086
1239
  navigator.mediaSession.setActionHandler("seekto", (details) => {
1087
1240
  if (details.seekTime !== null) {
@@ -1093,7 +1246,10 @@
1093
1246
  // Event Binding
1094
1247
  // ============================================
1095
1248
  /**
1096
- * 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.
1097
1253
  * @private
1098
1254
  */
1099
1255
  bindEvents() {
@@ -1114,7 +1270,8 @@
1114
1270
  window.addEventListener("resize", this.resizeHandler);
1115
1271
  }
1116
1272
  /**
1117
- * 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.
1118
1275
  * @private
1119
1276
  */
1120
1277
  setupResizeObserver() {
@@ -1131,9 +1288,20 @@
1131
1288
  // Audio Loading
1132
1289
  // ============================================
1133
1290
  /**
1134
- * Load audio file
1135
- * @param {string} url - Audio URL
1136
- * @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}).
1137
1305
  */
1138
1306
  async load(url) {
1139
1307
  try {
@@ -1173,7 +1341,7 @@
1173
1341
  this.updateBPMDisplay();
1174
1342
  }
1175
1343
  } catch (error) {
1176
- console.warn("Using placeholder waveform:", error);
1344
+ console.warn("[WaveformPlayer] Using placeholder waveform:", error);
1177
1345
  this.waveformData = generatePlaceholderWaveform(this.options.samples);
1178
1346
  }
1179
1347
  }
@@ -1184,18 +1352,26 @@
1184
1352
  this.options.onLoad(this);
1185
1353
  }
1186
1354
  } catch (error) {
1187
- console.error("Failed to load audio:", error);
1188
1355
  this.onError(error);
1189
1356
  } finally {
1190
1357
  this.setLoading(false);
1191
1358
  }
1192
1359
  }
1193
1360
  /**
1194
- * Load a new track
1195
- * @param {string} url - Audio URL
1196
- * @param {string} [title] - Track title
1197
- * @param {string} [subtitle] - Track subtitle
1198
- * @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`).
1199
1375
  * @returns {Promise<void>}
1200
1376
  */
1201
1377
  async loadTrack(url, title = null, subtitle = null, options = {}) {
@@ -1240,14 +1416,24 @@
1240
1416
  }
1241
1417
  this.options.markers = options.markers || [];
1242
1418
  await this.load(url);
1243
- this.play().catch(() => {
1244
- });
1419
+ if (options.autoplay !== false) {
1420
+ this.play()?.catch(() => {
1421
+ });
1422
+ }
1245
1423
  }
1246
1424
  // ============================================
1247
1425
  // Visualization
1248
1426
  // ============================================
1249
1427
  /**
1250
- * 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.
1251
1437
  * @private
1252
1438
  */
1253
1439
  setWaveformData(data) {
@@ -1276,7 +1462,9 @@
1276
1462
  this.drawWaveform();
1277
1463
  }
1278
1464
  /**
1279
- * 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.
1280
1468
  * @private
1281
1469
  */
1282
1470
  drawWaveform() {
@@ -1289,7 +1477,9 @@
1289
1477
  });
1290
1478
  }
1291
1479
  /**
1292
- * 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.
1293
1483
  * @private
1294
1484
  */
1295
1485
  resizeCanvas() {
@@ -1304,22 +1494,31 @@
1304
1494
  this.drawWaveform();
1305
1495
  }
1306
1496
  /**
1307
- * 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.
1308
1506
  * @private
1309
1507
  */
1310
1508
  renderMarkers() {
1311
1509
  if (!this.markersContainer) return;
1312
1510
  this.markersContainer.innerHTML = "";
1313
1511
  if (!this.options.showMarkers || !this.options.markers?.length) return;
1314
- if (!this.audio || !this.audio.duration || this.audio.duration === 0) {
1512
+ const duration = this.getSeekDuration();
1513
+ if (!duration) {
1315
1514
  return;
1316
1515
  }
1317
1516
  this.options.markers.forEach((marker, index) => {
1318
- if (marker.time > this.audio.duration) {
1319
- 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`);
1320
1519
  return;
1321
1520
  }
1322
- const position = marker.time / this.audio.duration * 100;
1521
+ const position = marker.time / duration * 100;
1323
1522
  const markerEl = document.createElement("button");
1324
1523
  markerEl.className = "waveform-marker";
1325
1524
  markerEl.style.left = `${position}%`;
@@ -1340,35 +1539,49 @@
1340
1539
  this.markersContainer.appendChild(markerEl);
1341
1540
  });
1342
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
+ }
1343
1554
  // ============================================
1344
1555
  // Event Handlers
1345
1556
  // ============================================
1346
1557
  /**
1347
- * 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.
1348
1566
  * @private
1567
+ * @fires WaveformPlayer#waveformplayer:request-seek
1349
1568
  */
1350
1569
  handleCanvasClick(event) {
1351
1570
  const rect = this.canvas.getBoundingClientRect();
1352
1571
  const x = event.clientX - rect.left;
1353
- const targetPercent = Math.max(0, Math.min(1, x / rect.width));
1572
+ const targetPercent = clamp(x / rect.width);
1354
1573
  if (this.options.audioMode === "external") {
1355
- const evt = new CustomEvent("waveformplayer:request-seek", {
1356
- bubbles: true,
1357
- cancelable: true,
1358
- detail: { ...this._buildTrackDetail(), percent: targetPercent }
1359
- });
1360
- this.container.dispatchEvent(evt);
1361
- if (!evt.defaultPrevented) {
1362
- this.progress = targetPercent;
1363
- this.drawWaveform?.();
1364
- }
1574
+ this._requestSeek(targetPercent);
1365
1575
  return;
1366
1576
  }
1367
1577
  if (!this.audio || !this.audio.duration) return;
1368
1578
  this.seekToPercent(targetPercent);
1369
1579
  }
1370
1580
  /**
1371
- * 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.
1372
1585
  * @private
1373
1586
  */
1374
1587
  setLoading(loading) {
@@ -1376,9 +1589,14 @@
1376
1589
  if (this.loadingEl) {
1377
1590
  this.loadingEl.style.display = loading ? "block" : "none";
1378
1591
  }
1592
+ if (this.seekEl) {
1593
+ this.seekEl.setAttribute("aria-busy", loading ? "true" : "false");
1594
+ }
1379
1595
  }
1380
1596
  /**
1381
- * 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.
1382
1600
  * @private
1383
1601
  */
1384
1602
  onMetadataLoaded() {
@@ -1390,79 +1608,91 @@
1390
1608
  this.updateSeekAccessibility();
1391
1609
  }
1392
1610
  /**
1393
- * 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.
1394
1616
  * @private
1395
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
+ */
1396
1634
  onPlay() {
1397
1635
  if (this.isDestroying) return;
1398
1636
  this.isPlaying = true;
1399
- if (this.playBtn) {
1400
- this.playBtn.classList.add("playing");
1401
- const playIcon = this.playBtn.querySelector(".waveform-icon-play");
1402
- const pauseIcon = this.playBtn.querySelector(".waveform-icon-pause");
1403
- if (playIcon) playIcon.style.display = "none";
1404
- if (pauseIcon) pauseIcon.style.display = "flex";
1405
- }
1637
+ this.setPlayButtonState(true);
1406
1638
  this.startSmoothUpdate();
1407
- this.container.dispatchEvent(new CustomEvent("waveformplayer:play", {
1408
- bubbles: true,
1409
- detail: { player: this, url: this.options.url }
1410
- }));
1639
+ this._emit("waveformplayer:play", { player: this, url: this.options.url });
1411
1640
  if (this.options.onPlay) {
1412
1641
  this.options.onPlay(this);
1413
1642
  }
1414
1643
  }
1415
1644
  /**
1416
- * 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.
1417
1649
  * @private
1650
+ * @fires WaveformPlayer#waveformplayer:pause
1418
1651
  */
1419
1652
  onPause() {
1420
1653
  if (this.isDestroying) return;
1421
1654
  this.isPlaying = false;
1422
- if (this.playBtn) {
1423
- this.playBtn.classList.remove("playing");
1424
- const playIcon = this.playBtn.querySelector(".waveform-icon-play");
1425
- const pauseIcon = this.playBtn.querySelector(".waveform-icon-pause");
1426
- if (playIcon) playIcon.style.display = "flex";
1427
- if (pauseIcon) pauseIcon.style.display = "none";
1428
- }
1655
+ this.setPlayButtonState(false);
1429
1656
  this.stopSmoothUpdate();
1430
- this.container.dispatchEvent(new CustomEvent("waveformplayer:pause", {
1431
- bubbles: true,
1432
- detail: { player: this, url: this.options.url }
1433
- }));
1657
+ this._emit("waveformplayer:pause", { player: this, url: this.options.url });
1434
1658
  if (this.options.onPause) {
1435
1659
  this.options.onPause(this);
1436
1660
  }
1437
1661
  }
1438
1662
  /**
1439
- * 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.
1440
1667
  * @private
1668
+ * @fires WaveformPlayer#waveformplayer:ended
1441
1669
  */
1442
1670
  onEnded() {
1443
1671
  if (this.isDestroying) return;
1672
+ const duration = this.audio.duration;
1444
1673
  this.progress = 0;
1445
1674
  this.audio.currentTime = 0;
1446
1675
  this.drawWaveform();
1447
1676
  if (this.currentTimeEl) {
1448
1677
  this.currentTimeEl.textContent = "0:00";
1449
1678
  }
1450
- this.container.dispatchEvent(new CustomEvent("waveformplayer:ended", {
1451
- bubbles: true,
1452
- detail: { player: this, url: this.options.url }
1453
- }));
1679
+ this._emit("waveformplayer:ended", { player: this, url: this.options.url, currentTime: duration, duration });
1454
1680
  this.onPause();
1455
1681
  if (this.options.onEnd) {
1456
1682
  this.options.onEnd(this);
1457
1683
  }
1458
1684
  }
1459
1685
  /**
1460
- * 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.
1461
1691
  * @private
1462
1692
  */
1463
1693
  onError(error) {
1464
1694
  if (this.isDestroying) return;
1465
- console.error("Audio error:", error);
1695
+ console.error("[WaveformPlayer] Audio error:", error);
1466
1696
  this.hasError = true;
1467
1697
  this.setLoading(false);
1468
1698
  if (this.errorEl) {
@@ -1482,7 +1712,10 @@
1482
1712
  // Progress Updates
1483
1713
  // ============================================
1484
1714
  /**
1485
- * 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.
1486
1719
  * @private
1487
1720
  */
1488
1721
  startSmoothUpdate() {
@@ -1496,7 +1729,7 @@
1496
1729
  this.updateTimer = requestAnimationFrame(update);
1497
1730
  }
1498
1731
  /**
1499
- * Stop smooth update animation
1732
+ * Cancel the smooth-update animation frame, if one is scheduled.
1500
1733
  * @private
1501
1734
  */
1502
1735
  stopSmoothUpdate() {
@@ -1506,8 +1739,15 @@
1506
1739
  }
1507
1740
  }
1508
1741
  /**
1509
- * 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.
1510
1749
  * @private
1750
+ * @fires WaveformPlayer#waveformplayer:timeupdate
1511
1751
  */
1512
1752
  updateProgress() {
1513
1753
  if (!this.audio || !this.audio.duration) return;
@@ -1519,15 +1759,13 @@
1519
1759
  if (this.currentTimeEl) {
1520
1760
  this.currentTimeEl.textContent = formatTime(this.audio.currentTime);
1521
1761
  }
1522
- this.container.dispatchEvent(new CustomEvent("waveformplayer:timeupdate", {
1523
- bubbles: true,
1524
- detail: {
1525
- player: this,
1526
- currentTime: this.audio.currentTime,
1527
- duration: this.audio.duration,
1528
- url: this.options.url
1529
- }
1530
- }));
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
+ });
1531
1769
  if (this.options.onTimeUpdate) {
1532
1770
  this.options.onTimeUpdate(this.audio.currentTime, this.audio.duration, this);
1533
1771
  }
@@ -1537,7 +1775,7 @@
1537
1775
  // UI Updates
1538
1776
  // ============================================
1539
1777
  /**
1540
- * Update BPM display
1778
+ * Show the detected BPM in the badge, once a value has been detected.
1541
1779
  * @private
1542
1780
  */
1543
1781
  updateBPMDisplay() {
@@ -1547,10 +1785,14 @@
1547
1785
  }
1548
1786
  }
1549
1787
  /**
1550
- * 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.
1551
1792
  * @private
1552
1793
  */
1553
1794
  updateSpeedUI() {
1795
+ if (!this.audio) return;
1554
1796
  const speedValue = this.container.querySelector(".speed-value");
1555
1797
  if (speedValue) {
1556
1798
  const rate = this.audio.playbackRate;
@@ -1577,19 +1819,19 @@
1577
1819
  * setPlayingState() / setProgress(). Calling preventDefault() on
1578
1820
  * the event lets the controller veto the play (state is unchanged).
1579
1821
  *
1580
- * @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
1581
1828
  */
1582
1829
  play() {
1583
1830
  if (this.options.singlePlay && _WaveformPlayer.currentlyPlaying && _WaveformPlayer.currentlyPlaying !== this) {
1584
1831
  _WaveformPlayer.currentlyPlaying.pause();
1585
1832
  }
1586
1833
  if (this.options.audioMode === "external") {
1587
- const evt = new CustomEvent("waveformplayer:request-play", {
1588
- bubbles: true,
1589
- cancelable: true,
1590
- detail: this._buildTrackDetail()
1591
- });
1592
- this.container.dispatchEvent(evt);
1834
+ const evt = this._emit("waveformplayer:request-play", this._buildTrackDetail(), true);
1593
1835
  if (!evt.defaultPrevented) {
1594
1836
  _WaveformPlayer.currentlyPlaying = this;
1595
1837
  }
@@ -1603,17 +1845,15 @@
1603
1845
  *
1604
1846
  * In `audioMode: 'external'`, dispatches `waveformplayer:request-pause`
1605
1847
  * (cancelable) and does NOT touch any audio element. See play().
1848
+ *
1849
+ * @fires WaveformPlayer#waveformplayer:request-pause
1606
1850
  */
1607
1851
  pause() {
1608
1852
  if (_WaveformPlayer.currentlyPlaying === this) {
1609
1853
  _WaveformPlayer.currentlyPlaying = null;
1610
1854
  }
1611
1855
  if (this.options.audioMode === "external") {
1612
- this.container.dispatchEvent(new CustomEvent("waveformplayer:request-pause", {
1613
- bubbles: true,
1614
- cancelable: true,
1615
- detail: this._buildTrackDetail()
1616
- }));
1856
+ this._emit("waveformplayer:request-pause", this._buildTrackDetail(), true);
1617
1857
  return;
1618
1858
  }
1619
1859
  this.audio.pause();
@@ -1632,8 +1872,12 @@
1632
1872
  url: this.options.url,
1633
1873
  title: this.options.title,
1634
1874
  subtitle: this.options.subtitle,
1635
- 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,
1636
1878
  artwork: this.options.artwork,
1879
+ markers: this.options.markers,
1880
+ waveform: this.options.waveform,
1637
1881
  id: this.id,
1638
1882
  player: this
1639
1883
  };
@@ -1643,31 +1887,26 @@
1643
1887
  * touching audio. Mirrors what onPlay()/onPause() do but skips the
1644
1888
  * audio-element interactions. Safe to call repeatedly — idempotent.
1645
1889
  *
1646
- * @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
1647
1898
  */
1648
1899
  setPlayingState(playing) {
1649
1900
  const wasPlaying = this.isPlaying;
1650
1901
  this.isPlaying = !!playing;
1651
- if (this.playBtn) {
1652
- this.playBtn.classList.toggle("playing", this.isPlaying);
1653
- const playIcon = this.playBtn.querySelector(".waveform-icon-play");
1654
- const pauseIcon = this.playBtn.querySelector(".waveform-icon-pause");
1655
- if (playIcon) playIcon.style.display = this.isPlaying ? "none" : "flex";
1656
- if (pauseIcon) pauseIcon.style.display = this.isPlaying ? "flex" : "none";
1657
- }
1902
+ this.setPlayButtonState(this.isPlaying);
1658
1903
  if (this.isPlaying && !wasPlaying) {
1659
1904
  this.startSmoothUpdate?.();
1660
- this.container.dispatchEvent(new CustomEvent("waveformplayer:play", {
1661
- bubbles: true,
1662
- detail: { player: this, url: this.options.url }
1663
- }));
1905
+ this._emit("waveformplayer:play", { player: this, url: this.options.url });
1664
1906
  if (this.options.onPlay) this.options.onPlay(this);
1665
1907
  } else if (!this.isPlaying && wasPlaying) {
1666
1908
  this.stopSmoothUpdate?.();
1667
- this.container.dispatchEvent(new CustomEvent("waveformplayer:pause", {
1668
- bubbles: true,
1669
- detail: { player: this, url: this.options.url }
1670
- }));
1909
+ this._emit("waveformplayer:pause", { player: this, url: this.options.url });
1671
1910
  if (this.options.onPause) this.options.onPause(this);
1672
1911
  }
1673
1912
  }
@@ -1676,28 +1915,45 @@
1676
1915
  * from an external clock (e.g. WaveformBar's audio element's
1677
1916
  * timeupdate). Drives the canvas redraw + the time displays.
1678
1917
  *
1679
- * @param {number} currentTime Current playback position in seconds.
1680
- * @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
1681
1928
  */
1682
1929
  setProgress(currentTime, duration) {
1683
1930
  if (!duration || duration <= 0) return;
1684
- this.progress = Math.max(0, Math.min(1, currentTime / duration));
1931
+ this.progress = clamp(currentTime / duration);
1685
1932
  if (this.currentTimeEl) this.currentTimeEl.textContent = formatTime(currentTime);
1686
- 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))) {
1687
1935
  this.totalTimeEl.textContent = formatTime(duration);
1688
1936
  this.totalTimeEl.dataset._extSet = "1";
1689
- this._extDuration = duration;
1937
+ this.totalTimeEl.dataset._extDur = String(duration);
1690
1938
  }
1691
1939
  this.drawWaveform?.();
1692
- this.container.dispatchEvent(new CustomEvent("waveformplayer:timeupdate", {
1693
- bubbles: true,
1694
- detail: { player: this, currentTime, duration, progress: this.progress }
1695
- }));
1696
- 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
+ }
1697
1951
  this.updateSeekAccessibility();
1698
1952
  }
1699
1953
  /**
1700
- * 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).
1701
1957
  */
1702
1958
  togglePlay() {
1703
1959
  if (this.isPlaying) {
@@ -1707,52 +1963,71 @@
1707
1963
  }
1708
1964
  }
1709
1965
  /**
1710
- * Seek to time in seconds
1711
- * @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.
1712
1971
  */
1713
1972
  seekTo(seconds) {
1714
1973
  if (this.audio && this.audio.duration) {
1715
- this.audio.currentTime = Math.max(0, Math.min(seconds, this.audio.duration));
1974
+ this.audio.currentTime = clamp(seconds, 0, this.audio.duration);
1716
1975
  this.updateProgress();
1717
1976
  }
1718
1977
  }
1719
1978
  /**
1720
- * Seek to percentage
1721
- * @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.
1722
1983
  */
1723
1984
  seekToPercent(percent) {
1724
1985
  if (this.audio && this.audio.duration) {
1725
- this.audio.currentTime = this.audio.duration * Math.max(0, Math.min(1, percent));
1986
+ this.audio.currentTime = this.audio.duration * clamp(percent);
1726
1987
  this.updateProgress();
1727
1988
  }
1728
1989
  }
1729
1990
  /**
1730
- * Set volume
1731
- * @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).
1732
1994
  */
1733
1995
  setVolume(volume) {
1734
- if (this.audio) {
1735
- 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);
1736
1999
  }
1737
2000
  }
1738
2001
  /**
1739
- * Set playback rate
1740
- * @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.
1741
2006
  */
1742
2007
  setPlaybackRate(rate) {
1743
2008
  if (!this.audio) return;
1744
- const clampedRate = Math.max(0.5, Math.min(2, rate));
2009
+ const clampedRate = clamp(rate, 0.5, 2);
1745
2010
  this.audio.playbackRate = clampedRate;
1746
2011
  this.options.playbackRate = clampedRate;
1747
2012
  this.updateSpeedUI();
1748
2013
  }
1749
2014
  /**
1750
- * 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
1751
2024
  */
1752
2025
  destroy() {
1753
2026
  this.isDestroying = true;
2027
+ this._emit("waveformplayer:destroy", { player: this, url: this.options.url });
1754
2028
  this.pause();
1755
2029
  this.stopSmoothUpdate();
2030
+ this._ac?.abort();
1756
2031
  if (this.resizeObserver) {
1757
2032
  this.resizeObserver.disconnect();
1758
2033
  this.resizeObserver = null;
@@ -1825,7 +2100,7 @@
1825
2100
  const result = await generateWaveform(url, samples);
1826
2101
  return result.peaks;
1827
2102
  } catch (error) {
1828
- console.error("Failed to generate waveform:", error);
2103
+ console.error("[WaveformPlayer] Failed to generate waveform:", error);
1829
2104
  throw error;
1830
2105
  }
1831
2106
  }
@@ -1878,8 +2153,10 @@
1878
2153
  };
1879
2154
 
1880
2155
  // src/js/index.js
2156
+ WaveformPlayer.utils = { formatTime, extractTitleFromUrl, escapeHtml, isSafeHref };
2157
+ var isBrowser = () => typeof window !== "undefined" && typeof document !== "undefined";
1881
2158
  function autoInit() {
1882
- if (typeof document === "undefined") return;
2159
+ if (!isBrowser()) return;
1883
2160
  const elements = document.querySelectorAll("[data-waveform-player]");
1884
2161
  elements.forEach((element) => {
1885
2162
  if (element.dataset.waveformInitialized === "true") return;
@@ -1887,11 +2164,11 @@
1887
2164
  new WaveformPlayer(element);
1888
2165
  element.dataset.waveformInitialized = "true";
1889
2166
  } catch (error) {
1890
- console.error("Failed to initialize WaveformPlayer:", error, element);
2167
+ console.error("[WaveformPlayer] Failed to initialize:", error, element);
1891
2168
  }
1892
2169
  });
1893
2170
  }
1894
- if (typeof document !== "undefined") {
2171
+ if (isBrowser()) {
1895
2172
  if (document.readyState === "loading") {
1896
2173
  document.addEventListener("DOMContentLoaded", autoInit);
1897
2174
  } else {
@@ -1899,7 +2176,7 @@
1899
2176
  }
1900
2177
  }
1901
2178
  WaveformPlayer.init = autoInit;
1902
- if (typeof window !== "undefined") {
2179
+ if (isBrowser()) {
1903
2180
  window.WaveformPlayer = WaveformPlayer;
1904
2181
  }
1905
2182
  var index_default = WaveformPlayer;