@arraypress/waveform-player 1.7.2 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 = {}) {
@@ -1239,15 +1415,26 @@
1239
1415
  this.artworkEl.src = options.artwork;
1240
1416
  }
1241
1417
  this.options.markers = options.markers || [];
1418
+ this.options.waveform = options.waveform || null;
1242
1419
  await this.load(url);
1243
- this.play().catch(() => {
1244
- });
1420
+ if (options.autoplay !== false) {
1421
+ this.play()?.catch(() => {
1422
+ });
1423
+ }
1245
1424
  }
1246
1425
  // ============================================
1247
1426
  // Visualization
1248
1427
  // ============================================
1249
1428
  /**
1250
- * Set waveform data
1429
+ * Normalise externally-supplied waveform data into `this.waveformData` and
1430
+ * redraw.
1431
+ *
1432
+ * Accepts several shapes: a `.json` URL (fetched async; peaks and any
1433
+ * embedded `markers` are applied on resolve), a JSON-encoded array string,
1434
+ * a comma-separated number string, or a plain number array. Malformed
1435
+ * input degrades to an empty array rather than throwing.
1436
+ * @param {string|number[]} data - Peaks as an array, a JSON/CSV string, or
1437
+ * a URL to a `.json` peaks file.
1251
1438
  * @private
1252
1439
  */
1253
1440
  setWaveformData(data) {
@@ -1276,7 +1463,9 @@
1276
1463
  this.drawWaveform();
1277
1464
  }
1278
1465
  /**
1279
- * Draw waveform
1466
+ * Render the current waveform + progress to the canvas via the shared
1467
+ * {@link draw} routine, passing the resolved style and colours. No-op
1468
+ * before the context exists or while there is no peak data.
1280
1469
  * @private
1281
1470
  */
1282
1471
  drawWaveform() {
@@ -1289,7 +1478,9 @@
1289
1478
  });
1290
1479
  }
1291
1480
  /**
1292
- * Resize canvas
1481
+ * Re-fit the canvas backing store to its parent's width and the configured
1482
+ * height, scaled by the device pixel ratio for crisp rendering, then
1483
+ * redraw. Guards against running after destruction.
1293
1484
  * @private
1294
1485
  */
1295
1486
  resizeCanvas() {
@@ -1304,22 +1495,31 @@
1304
1495
  this.drawWaveform();
1305
1496
  }
1306
1497
  /**
1307
- * Render markers on the waveform
1498
+ * Render the configured cue markers as positioned, clickable buttons over
1499
+ * the waveform.
1500
+ *
1501
+ * Clears any existing markers first, then bails out unless `showMarkers` is
1502
+ * on, markers exist, and a duration is known (via the mode-agnostic
1503
+ * {@link WaveformPlayer#getSeekDuration}). Each marker is placed by its
1504
+ * time-as-percentage, carries a tooltip and ARIA label, and seeks on click
1505
+ * (also starting playback when `playOnSeek` is set and currently paused).
1506
+ * Markers past the track duration are skipped with a warning.
1308
1507
  * @private
1309
1508
  */
1310
1509
  renderMarkers() {
1311
1510
  if (!this.markersContainer) return;
1312
1511
  this.markersContainer.innerHTML = "";
1313
1512
  if (!this.options.showMarkers || !this.options.markers?.length) return;
1314
- if (!this.audio || !this.audio.duration || this.audio.duration === 0) {
1513
+ const duration = this.getSeekDuration();
1514
+ if (!duration) {
1315
1515
  return;
1316
1516
  }
1317
1517
  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`);
1518
+ if (marker.time > duration) {
1519
+ console.warn(`[WaveformPlayer] Marker "${marker.label}" at ${marker.time}s exceeds audio duration of ${duration}s`);
1320
1520
  return;
1321
1521
  }
1322
- const position = marker.time / this.audio.duration * 100;
1522
+ const position = marker.time / duration * 100;
1323
1523
  const markerEl = document.createElement("button");
1324
1524
  markerEl.className = "waveform-marker";
1325
1525
  markerEl.style.left = `${position}%`;
@@ -1340,35 +1540,49 @@
1340
1540
  this.markersContainer.appendChild(markerEl);
1341
1541
  });
1342
1542
  }
1543
+ /**
1544
+ * Highlight the marker at `index` (toggling an `active` class) and clear
1545
+ * the rest. Pass `null` to clear all. Lets an external controller (e.g. a
1546
+ * DJ bar) reflect the current section without reaching into the player's
1547
+ * private marker DOM.
1548
+ * @param {number|null} index - Marker index to activate, or `null` to clear.
1549
+ */
1550
+ setActiveMarker(index) {
1551
+ if (!this.markersContainer) return;
1552
+ const markers = this.markersContainer.querySelectorAll(".waveform-marker");
1553
+ markers.forEach((el, i) => el.classList.toggle("active", i === index));
1554
+ }
1343
1555
  // ============================================
1344
1556
  // Event Handlers
1345
1557
  // ============================================
1346
1558
  /**
1347
- * Handle canvas click
1559
+ * Seek to the clicked horizontal position on the waveform canvas.
1560
+ *
1561
+ * Converts the click X into a 0..1 percentage. In external mode it
1562
+ * dispatches a cancelable `waveformplayer:request-seek` event (updating the
1563
+ * local visual optimistically unless the controller vetoes it); in self
1564
+ * mode it seeks the owned `<audio>` via
1565
+ * {@link WaveformPlayer#seekToPercent}.
1566
+ * @param {MouseEvent} event - The canvas click event.
1348
1567
  * @private
1568
+ * @fires WaveformPlayer#waveformplayer:request-seek
1349
1569
  */
1350
1570
  handleCanvasClick(event) {
1351
1571
  const rect = this.canvas.getBoundingClientRect();
1352
1572
  const x = event.clientX - rect.left;
1353
- const targetPercent = Math.max(0, Math.min(1, x / rect.width));
1573
+ const targetPercent = clamp(x / rect.width);
1354
1574
  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
- }
1575
+ this._requestSeek(targetPercent);
1365
1576
  return;
1366
1577
  }
1367
1578
  if (!this.audio || !this.audio.duration) return;
1368
1579
  this.seekToPercent(targetPercent);
1369
1580
  }
1370
1581
  /**
1371
- * Set loading state
1582
+ * Toggle the loading state: show/hide the spinner overlay and set
1583
+ * `aria-busy` on the accessible seek slider so assistive tech knows the
1584
+ * player is fetching/decoding.
1585
+ * @param {boolean} loading - True while audio is loading.
1372
1586
  * @private
1373
1587
  */
1374
1588
  setLoading(loading) {
@@ -1376,9 +1590,14 @@
1376
1590
  if (this.loadingEl) {
1377
1591
  this.loadingEl.style.display = loading ? "block" : "none";
1378
1592
  }
1593
+ if (this.seekEl) {
1594
+ this.seekEl.setAttribute("aria-busy", loading ? "true" : "false");
1595
+ }
1379
1596
  }
1380
1597
  /**
1381
- * Handle metadata loaded
1598
+ * `loadedmetadata` handler (self mode): write the total-time display, now
1599
+ * that duration is known re-render markers, and publish duration to the
1600
+ * accessible seek slider. No-op during destruction.
1382
1601
  * @private
1383
1602
  */
1384
1603
  onMetadataLoaded() {
@@ -1390,79 +1609,91 @@
1390
1609
  this.updateSeekAccessibility();
1391
1610
  }
1392
1611
  /**
1393
- * Handle play event
1612
+ * Reflect play/pause state on the transport button: toggle the `playing`
1613
+ * class and swap the play/pause icon visibility. The single source of
1614
+ * truth shared by `onPlay`, `onPause`, and the external-mode
1615
+ * `setPlayingState` pump so they can't drift. No-op without a button.
1616
+ * @param {boolean} isPlaying - Whether playback is active.
1394
1617
  * @private
1395
1618
  */
1619
+ setPlayButtonState(isPlaying) {
1620
+ if (!this.playBtn) return;
1621
+ this.playBtn.classList.toggle("playing", isPlaying);
1622
+ const playIcon = this.playBtn.querySelector(".waveform-icon-play");
1623
+ const pauseIcon = this.playBtn.querySelector(".waveform-icon-pause");
1624
+ if (playIcon) playIcon.style.display = isPlaying ? "none" : "flex";
1625
+ if (pauseIcon) pauseIcon.style.display = isPlaying ? "flex" : "none";
1626
+ }
1627
+ /**
1628
+ * `play` handler (self mode): set the playing flag, swap the button to its
1629
+ * pause icon, start the smooth progress loop, dispatch
1630
+ * `waveformplayer:play`, and fire the `onPlay` callback. No-op during
1631
+ * destruction.
1632
+ * @private
1633
+ * @fires WaveformPlayer#waveformplayer:play
1634
+ */
1396
1635
  onPlay() {
1397
1636
  if (this.isDestroying) return;
1398
1637
  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
- }
1638
+ this.setPlayButtonState(true);
1406
1639
  this.startSmoothUpdate();
1407
- this.container.dispatchEvent(new CustomEvent("waveformplayer:play", {
1408
- bubbles: true,
1409
- detail: { player: this, url: this.options.url }
1410
- }));
1640
+ this._emit("waveformplayer:play", { player: this, url: this.options.url });
1411
1641
  if (this.options.onPlay) {
1412
1642
  this.options.onPlay(this);
1413
1643
  }
1414
1644
  }
1415
1645
  /**
1416
- * Handle pause event
1646
+ * `pause` handler (self mode): clear the playing flag, swap the button back
1647
+ * to its play icon, stop the smooth progress loop, dispatch
1648
+ * `waveformplayer:pause`, and fire the `onPause` callback. No-op during
1649
+ * destruction.
1417
1650
  * @private
1651
+ * @fires WaveformPlayer#waveformplayer:pause
1418
1652
  */
1419
1653
  onPause() {
1420
1654
  if (this.isDestroying) return;
1421
1655
  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
- }
1656
+ this.setPlayButtonState(false);
1429
1657
  this.stopSmoothUpdate();
1430
- this.container.dispatchEvent(new CustomEvent("waveformplayer:pause", {
1431
- bubbles: true,
1432
- detail: { player: this, url: this.options.url }
1433
- }));
1658
+ this._emit("waveformplayer:pause", { player: this, url: this.options.url });
1434
1659
  if (this.options.onPause) {
1435
1660
  this.options.onPause(this);
1436
1661
  }
1437
1662
  }
1438
1663
  /**
1439
- * Handle ended event
1664
+ * `ended` handler (self mode): reset progress and `currentTime` to the
1665
+ * start, redraw, reset the time display, dispatch `waveformplayer:ended`
1666
+ * (carrying the final time), run {@link WaveformPlayer#onPause}, and fire
1667
+ * the `onEnd` callback. No-op during destruction.
1440
1668
  * @private
1669
+ * @fires WaveformPlayer#waveformplayer:ended
1441
1670
  */
1442
1671
  onEnded() {
1443
1672
  if (this.isDestroying) return;
1673
+ const duration = this.audio.duration;
1444
1674
  this.progress = 0;
1445
1675
  this.audio.currentTime = 0;
1446
1676
  this.drawWaveform();
1447
1677
  if (this.currentTimeEl) {
1448
1678
  this.currentTimeEl.textContent = "0:00";
1449
1679
  }
1450
- this.container.dispatchEvent(new CustomEvent("waveformplayer:ended", {
1451
- bubbles: true,
1452
- detail: { player: this, url: this.options.url }
1453
- }));
1680
+ this._emit("waveformplayer:ended", { player: this, url: this.options.url, currentTime: duration, duration });
1454
1681
  this.onPause();
1455
1682
  if (this.options.onEnd) {
1456
1683
  this.options.onEnd(this);
1457
1684
  }
1458
1685
  }
1459
1686
  /**
1460
- * Handle error event
1687
+ * `error` handler: set the error flag, hide the spinner, reveal the error
1688
+ * overlay, dim the canvas, disable the play button, and fire the `onError`
1689
+ * callback. No-op during destruction.
1690
+ * @param {Event|Error} error - The audio error event, or an Error thrown
1691
+ * during loading.
1461
1692
  * @private
1462
1693
  */
1463
1694
  onError(error) {
1464
1695
  if (this.isDestroying) return;
1465
- console.error("Audio error:", error);
1696
+ console.error("[WaveformPlayer] Audio error:", error);
1466
1697
  this.hasError = true;
1467
1698
  this.setLoading(false);
1468
1699
  if (this.errorEl) {
@@ -1482,7 +1713,10 @@
1482
1713
  // Progress Updates
1483
1714
  // ============================================
1484
1715
  /**
1485
- * Start smooth update animation
1716
+ * Start the `requestAnimationFrame` loop that drives smooth progress
1717
+ * updates while playing (self mode only — external mode is redrawn by
1718
+ * controller {@link WaveformPlayer#setProgress} pushes). Cancels any
1719
+ * existing loop first so it's safe to call repeatedly.
1486
1720
  * @private
1487
1721
  */
1488
1722
  startSmoothUpdate() {
@@ -1496,7 +1730,7 @@
1496
1730
  this.updateTimer = requestAnimationFrame(update);
1497
1731
  }
1498
1732
  /**
1499
- * Stop smooth update animation
1733
+ * Cancel the smooth-update animation frame, if one is scheduled.
1500
1734
  * @private
1501
1735
  */
1502
1736
  stopSmoothUpdate() {
@@ -1506,8 +1740,15 @@
1506
1740
  }
1507
1741
  }
1508
1742
  /**
1509
- * Update progress
1743
+ * Recompute progress from the owned `<audio>` clock and reflect it
1744
+ * everywhere (self mode only — external mode uses
1745
+ * {@link WaveformPlayer#setProgress}).
1746
+ *
1747
+ * Redraws the canvas when progress moves meaningfully, updates the
1748
+ * current-time display, dispatches `waveformplayer:timeupdate`, fires the
1749
+ * `onTimeUpdate` callback, and refreshes the accessible slider values.
1510
1750
  * @private
1751
+ * @fires WaveformPlayer#waveformplayer:timeupdate
1511
1752
  */
1512
1753
  updateProgress() {
1513
1754
  if (!this.audio || !this.audio.duration) return;
@@ -1519,15 +1760,13 @@
1519
1760
  if (this.currentTimeEl) {
1520
1761
  this.currentTimeEl.textContent = formatTime(this.audio.currentTime);
1521
1762
  }
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
- }));
1763
+ this._emit("waveformplayer:timeupdate", {
1764
+ player: this,
1765
+ currentTime: this.audio.currentTime,
1766
+ duration: this.audio.duration,
1767
+ progress: this.progress,
1768
+ url: this.options.url
1769
+ });
1531
1770
  if (this.options.onTimeUpdate) {
1532
1771
  this.options.onTimeUpdate(this.audio.currentTime, this.audio.duration, this);
1533
1772
  }
@@ -1537,7 +1776,7 @@
1537
1776
  // UI Updates
1538
1777
  // ============================================
1539
1778
  /**
1540
- * Update BPM display
1779
+ * Show the detected BPM in the badge, once a value has been detected.
1541
1780
  * @private
1542
1781
  */
1543
1782
  updateBPMDisplay() {
@@ -1547,10 +1786,14 @@
1547
1786
  }
1548
1787
  }
1549
1788
  /**
1550
- * Update speed UI to reflect current rate
1789
+ * Sync the speed control's label and the menu's active-option highlight to
1790
+ * the audio element's current `playbackRate`. No-op in external mode (no
1791
+ * owned `<audio>`), which also avoids reading `playbackRate` before the
1792
+ * element exists.
1551
1793
  * @private
1552
1794
  */
1553
1795
  updateSpeedUI() {
1796
+ if (!this.audio) return;
1554
1797
  const speedValue = this.container.querySelector(".speed-value");
1555
1798
  if (speedValue) {
1556
1799
  const rate = this.audio.playbackRate;
@@ -1577,19 +1820,19 @@
1577
1820
  * setPlayingState() / setProgress(). Calling preventDefault() on
1578
1821
  * the event lets the controller veto the play (state is unchanged).
1579
1822
  *
1580
- * @return {Promise|undefined}
1823
+ * When `singlePlay` is enabled, any other currently-playing instance is
1824
+ * paused first.
1825
+ *
1826
+ * @return {Promise|undefined} The promise from `HTMLMediaElement.play()` in
1827
+ * self mode; `undefined` in external mode.
1828
+ * @fires WaveformPlayer#waveformplayer:request-play
1581
1829
  */
1582
1830
  play() {
1583
1831
  if (this.options.singlePlay && _WaveformPlayer.currentlyPlaying && _WaveformPlayer.currentlyPlaying !== this) {
1584
1832
  _WaveformPlayer.currentlyPlaying.pause();
1585
1833
  }
1586
1834
  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);
1835
+ const evt = this._emit("waveformplayer:request-play", this._buildTrackDetail(), true);
1593
1836
  if (!evt.defaultPrevented) {
1594
1837
  _WaveformPlayer.currentlyPlaying = this;
1595
1838
  }
@@ -1603,17 +1846,15 @@
1603
1846
  *
1604
1847
  * In `audioMode: 'external'`, dispatches `waveformplayer:request-pause`
1605
1848
  * (cancelable) and does NOT touch any audio element. See play().
1849
+ *
1850
+ * @fires WaveformPlayer#waveformplayer:request-pause
1606
1851
  */
1607
1852
  pause() {
1608
1853
  if (_WaveformPlayer.currentlyPlaying === this) {
1609
1854
  _WaveformPlayer.currentlyPlaying = null;
1610
1855
  }
1611
1856
  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
- }));
1857
+ this._emit("waveformplayer:request-pause", this._buildTrackDetail(), true);
1617
1858
  return;
1618
1859
  }
1619
1860
  this.audio.pause();
@@ -1632,8 +1873,12 @@
1632
1873
  url: this.options.url,
1633
1874
  title: this.options.title,
1634
1875
  subtitle: this.options.subtitle,
1635
- artist: this.options.artist,
1876
+ // Core has no separate `artist` option; mirror subtitle so the
1877
+ // published event detail is self-consistent for controllers.
1878
+ artist: this.options.artist || this.options.subtitle,
1636
1879
  artwork: this.options.artwork,
1880
+ markers: this.options.markers,
1881
+ waveform: this.options.waveform,
1637
1882
  id: this.id,
1638
1883
  player: this
1639
1884
  };
@@ -1643,31 +1888,26 @@
1643
1888
  * touching audio. Mirrors what onPlay()/onPause() do but skips the
1644
1889
  * audio-element interactions. Safe to call repeatedly — idempotent.
1645
1890
  *
1646
- * @param {boolean} playing
1891
+ * Only dispatches `waveformplayer:play`/`waveformplayer:pause` (and runs
1892
+ * the matching callback) on an actual transition, starting/stopping the
1893
+ * smooth-update loop accordingly.
1894
+ *
1895
+ * @param {boolean} playing - True to enter the playing state, false to
1896
+ * enter the paused state.
1897
+ * @fires WaveformPlayer#waveformplayer:play
1898
+ * @fires WaveformPlayer#waveformplayer:pause
1647
1899
  */
1648
1900
  setPlayingState(playing) {
1649
1901
  const wasPlaying = this.isPlaying;
1650
1902
  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
- }
1903
+ this.setPlayButtonState(this.isPlaying);
1658
1904
  if (this.isPlaying && !wasPlaying) {
1659
1905
  this.startSmoothUpdate?.();
1660
- this.container.dispatchEvent(new CustomEvent("waveformplayer:play", {
1661
- bubbles: true,
1662
- detail: { player: this, url: this.options.url }
1663
- }));
1906
+ this._emit("waveformplayer:play", { player: this, url: this.options.url });
1664
1907
  if (this.options.onPlay) this.options.onPlay(this);
1665
1908
  } else if (!this.isPlaying && wasPlaying) {
1666
1909
  this.stopSmoothUpdate?.();
1667
- this.container.dispatchEvent(new CustomEvent("waveformplayer:pause", {
1668
- bubbles: true,
1669
- detail: { player: this, url: this.options.url }
1670
- }));
1910
+ this._emit("waveformplayer:pause", { player: this, url: this.options.url });
1671
1911
  if (this.options.onPause) this.options.onPause(this);
1672
1912
  }
1673
1913
  }
@@ -1676,28 +1916,45 @@
1676
1916
  * from an external clock (e.g. WaveformBar's audio element's
1677
1917
  * timeupdate). Drives the canvas redraw + the time displays.
1678
1918
  *
1679
- * @param {number} currentTime Current playback position in seconds.
1680
- * @param {number} duration Total track duration in seconds.
1919
+ * Redraws the canvas, updates the current/total time displays, stores the
1920
+ * external duration for the accessible slider, dispatches
1921
+ * `waveformplayer:timeupdate`, runs `onTimeUpdate`, and synthesizes a
1922
+ * one-shot `waveformplayer:ended` (with `onEnd`) when progress reaches the
1923
+ * end. No-op for a non-positive duration.
1924
+ *
1925
+ * @param {number} currentTime - Current playback position in seconds.
1926
+ * @param {number} duration - Total track duration in seconds.
1927
+ * @fires WaveformPlayer#waveformplayer:timeupdate
1928
+ * @fires WaveformPlayer#waveformplayer:ended
1681
1929
  */
1682
1930
  setProgress(currentTime, duration) {
1683
1931
  if (!duration || duration <= 0) return;
1684
- this.progress = Math.max(0, Math.min(1, currentTime / duration));
1932
+ this.progress = clamp(currentTime / duration);
1685
1933
  if (this.currentTimeEl) this.currentTimeEl.textContent = formatTime(currentTime);
1686
- if (this.totalTimeEl && (!this.totalTimeEl.dataset._extSet || this._extDuration !== duration)) {
1934
+ this._extDuration = duration;
1935
+ if (this.totalTimeEl && (!this.totalTimeEl.dataset._extSet || this.totalTimeEl.dataset._extDur !== String(duration))) {
1687
1936
  this.totalTimeEl.textContent = formatTime(duration);
1688
1937
  this.totalTimeEl.dataset._extSet = "1";
1689
- this._extDuration = duration;
1938
+ this.totalTimeEl.dataset._extDur = String(duration);
1690
1939
  }
1691
1940
  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);
1941
+ this._emit("waveformplayer:timeupdate", { player: this, currentTime, duration, progress: this.progress, url: this.options.url });
1942
+ if (this.options.onTimeUpdate) this.options.onTimeUpdate(currentTime, duration, this);
1943
+ if (this.progress >= 1) {
1944
+ if (!this._extEnded) {
1945
+ this._extEnded = true;
1946
+ this._emit("waveformplayer:ended", { player: this, url: this.options.url, currentTime: duration, duration });
1947
+ if (this.options.onEnd) this.options.onEnd(this);
1948
+ }
1949
+ } else {
1950
+ this._extEnded = false;
1951
+ }
1697
1952
  this.updateSeekAccessibility();
1698
1953
  }
1699
1954
  /**
1700
- * Toggle play/pause
1955
+ * Toggle between play and pause based on the current `isPlaying` state.
1956
+ * Works in both audio modes (in external mode it routes through the
1957
+ * request-play/pause events).
1701
1958
  */
1702
1959
  togglePlay() {
1703
1960
  if (this.isPlaying) {
@@ -1707,52 +1964,71 @@
1707
1964
  }
1708
1965
  }
1709
1966
  /**
1710
- * Seek to time in seconds
1711
- * @param {number} seconds - Time in seconds
1967
+ * Seek the owned `<audio>` element to an absolute time, clamped to
1968
+ * `[0, duration]`, and refresh progress. Self mode only — a no-op when
1969
+ * there is no audio element or duration. External-mode keyboard/click
1970
+ * seeks go through {@link WaveformPlayer#seekToSeconds} instead.
1971
+ * @param {number} seconds - Target time in seconds.
1712
1972
  */
1713
1973
  seekTo(seconds) {
1714
1974
  if (this.audio && this.audio.duration) {
1715
- this.audio.currentTime = Math.max(0, Math.min(seconds, this.audio.duration));
1975
+ this.audio.currentTime = clamp(seconds, 0, this.audio.duration);
1716
1976
  this.updateProgress();
1717
1977
  }
1718
1978
  }
1719
1979
  /**
1720
- * Seek to percentage
1721
- * @param {number} percent - Percentage (0-1)
1980
+ * Seek the owned `<audio>` element to a fraction of the track, clamped to
1981
+ * `[0, 1]`, and refresh progress. Self mode only — a no-op without an audio
1982
+ * element or duration.
1983
+ * @param {number} percent - Position as a fraction from 0 to 1.
1722
1984
  */
1723
1985
  seekToPercent(percent) {
1724
1986
  if (this.audio && this.audio.duration) {
1725
- this.audio.currentTime = this.audio.duration * Math.max(0, Math.min(1, percent));
1987
+ this.audio.currentTime = this.audio.duration * clamp(percent);
1726
1988
  this.updateProgress();
1727
1989
  }
1728
1990
  }
1729
1991
  /**
1730
- * Set volume
1731
- * @param {number} volume - Volume (0-1)
1992
+ * Set the owned `<audio>` element's volume, clamped to `[0, 1]`. Self mode
1993
+ * only a no-op in external mode where the controller owns volume.
1994
+ * @param {number} volume - Volume from 0 (silent) to 1 (full).
1732
1995
  */
1733
1996
  setVolume(volume) {
1734
- if (this.audio) {
1735
- this.audio.volume = Math.max(0, Math.min(1, volume));
1997
+ const v = Number(volume);
1998
+ if (this.audio && Number.isFinite(v)) {
1999
+ this.audio.volume = clamp(v);
1736
2000
  }
1737
2001
  }
1738
2002
  /**
1739
- * Set playback rate
1740
- * @param {number} rate - Playback rate (0.5 to 2)
2003
+ * Set the owned `<audio>` element's playback rate (clamped to 0.5–2),
2004
+ * persist it onto `this.options.playbackRate`, and refresh the speed UI.
2005
+ * Self mode only — a no-op in external mode.
2006
+ * @param {number} rate - Desired playback rate; clamped to the 0.5–2 range.
1741
2007
  */
1742
2008
  setPlaybackRate(rate) {
1743
2009
  if (!this.audio) return;
1744
- const clampedRate = Math.max(0.5, Math.min(2, rate));
2010
+ const clampedRate = clamp(rate, 0.5, 2);
1745
2011
  this.audio.playbackRate = clampedRate;
1746
2012
  this.options.playbackRate = clampedRate;
1747
2013
  this.updateSpeedUI();
1748
2014
  }
1749
2015
  /**
1750
- * Destroy player instance
2016
+ * Tear down the player and release all resources.
2017
+ *
2018
+ * Flags destruction (so in-flight handlers bail), dispatches
2019
+ * `waveformplayer:destroy`, stops playback and the animation loop, aborts
2020
+ * every listener registered on the instance signal, disconnects the resize
2021
+ * observer, removes the window-resize handler, drops the instance from the
2022
+ * static map and `currentlyPlaying`, resets/releases the audio element, and
2023
+ * empties the container.
2024
+ * @fires WaveformPlayer#waveformplayer:destroy
1751
2025
  */
1752
2026
  destroy() {
1753
2027
  this.isDestroying = true;
2028
+ this._emit("waveformplayer:destroy", { player: this, url: this.options.url });
1754
2029
  this.pause();
1755
2030
  this.stopSmoothUpdate();
2031
+ this._ac?.abort();
1756
2032
  if (this.resizeObserver) {
1757
2033
  this.resizeObserver.disconnect();
1758
2034
  this.resizeObserver = null;
@@ -1825,7 +2101,7 @@
1825
2101
  const result = await generateWaveform(url, samples);
1826
2102
  return result.peaks;
1827
2103
  } catch (error) {
1828
- console.error("Failed to generate waveform:", error);
2104
+ console.error("[WaveformPlayer] Failed to generate waveform:", error);
1829
2105
  throw error;
1830
2106
  }
1831
2107
  }
@@ -1878,8 +2154,10 @@
1878
2154
  };
1879
2155
 
1880
2156
  // src/js/index.js
2157
+ WaveformPlayer.utils = { formatTime, extractTitleFromUrl, escapeHtml, isSafeHref };
2158
+ var isBrowser = () => typeof window !== "undefined" && typeof document !== "undefined";
1881
2159
  function autoInit() {
1882
- if (typeof document === "undefined") return;
2160
+ if (!isBrowser()) return;
1883
2161
  const elements = document.querySelectorAll("[data-waveform-player]");
1884
2162
  elements.forEach((element) => {
1885
2163
  if (element.dataset.waveformInitialized === "true") return;
@@ -1887,11 +2165,11 @@
1887
2165
  new WaveformPlayer(element);
1888
2166
  element.dataset.waveformInitialized = "true";
1889
2167
  } catch (error) {
1890
- console.error("Failed to initialize WaveformPlayer:", error, element);
2168
+ console.error("[WaveformPlayer] Failed to initialize:", error, element);
1891
2169
  }
1892
2170
  });
1893
2171
  }
1894
- if (typeof document !== "undefined") {
2172
+ if (isBrowser()) {
1895
2173
  if (document.readyState === "loading") {
1896
2174
  document.addEventListener("DOMContentLoaded", autoInit);
1897
2175
  } else {
@@ -1899,7 +2177,7 @@
1899
2177
  }
1900
2178
  }
1901
2179
  WaveformPlayer.init = autoInit;
1902
- if (typeof window !== "undefined") {
2180
+ if (isBrowser()) {
1903
2181
  window.WaveformPlayer = WaveformPlayer;
1904
2182
  }
1905
2183
  var index_default = WaveformPlayer;