@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.
@@ -0,0 +1,2208 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
5
+ var __export = (target, all) => {
6
+ for (var name in all)
7
+ __defProp(target, name, { get: all[name], enumerable: true });
8
+ };
9
+ var __copyProps = (to, from, except, desc) => {
10
+ if (from && typeof from === "object" || typeof from === "function") {
11
+ for (let key of __getOwnPropNames(from))
12
+ if (!__hasOwnProp.call(to, key) && key !== except)
13
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
14
+ }
15
+ return to;
16
+ };
17
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
18
+
19
+ // src/js/index.js
20
+ var index_exports = {};
21
+ __export(index_exports, {
22
+ WaveformPlayer: () => WaveformPlayer,
23
+ default: () => index_default
24
+ });
25
+ module.exports = __toCommonJS(index_exports);
26
+
27
+ // src/js/utils.js
28
+ function escapeHtml(str) {
29
+ return String(str == null ? "" : str).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
30
+ }
31
+ function isSafeHref(url) {
32
+ if (typeof url !== "string" || url === "") return false;
33
+ try {
34
+ const u = new URL(url, "http://localhost/");
35
+ return u.protocol === "http:" || u.protocol === "https:";
36
+ } catch (e) {
37
+ return false;
38
+ }
39
+ }
40
+ function clamp(value, min = 0, max = 1) {
41
+ return Math.max(min, Math.min(value, max));
42
+ }
43
+ function parseBoolAttr(value) {
44
+ return value === void 0 ? void 0 : value === "true";
45
+ }
46
+ function parseColorValue(value) {
47
+ if (typeof value === "string" && value.trim().startsWith("[")) {
48
+ try {
49
+ return JSON.parse(value);
50
+ } catch (e) {
51
+ }
52
+ }
53
+ return value;
54
+ }
55
+ function parseDataAttributes(element) {
56
+ const options = {};
57
+ const setBool = (optKey, dataKey = optKey) => {
58
+ const v = parseBoolAttr(element.dataset[dataKey]);
59
+ if (v !== void 0) options[optKey] = v;
60
+ };
61
+ const setNum = (optKey, dataKey = optKey, float = false) => {
62
+ const raw = element.dataset[dataKey];
63
+ if (raw) options[optKey] = float ? parseFloat(raw) : parseInt(raw, 10);
64
+ };
65
+ const setJson = (optKey, dataKey = optKey) => {
66
+ const raw = element.dataset[dataKey];
67
+ if (!raw) return;
68
+ try {
69
+ options[optKey] = JSON.parse(raw);
70
+ } catch (e) {
71
+ console.warn(`[WaveformPlayer] Invalid ${dataKey} JSON:`, e);
72
+ }
73
+ };
74
+ if (element.dataset.src) options.url = element.dataset.src;
75
+ if (element.dataset.url) options.url = element.dataset.url;
76
+ setNum("height");
77
+ setNum("samples");
78
+ if (element.dataset.preload) {
79
+ options.preload = element.dataset.preload;
80
+ }
81
+ if (element.dataset.audioMode) options.audioMode = element.dataset.audioMode;
82
+ if (element.dataset.style) options.waveformStyle = element.dataset.style;
83
+ if (element.dataset.waveformStyle) options.waveformStyle = element.dataset.waveformStyle;
84
+ setNum("barWidth");
85
+ setNum("barSpacing");
86
+ setNum("barRadius");
87
+ if (element.dataset.buttonAlign) options.buttonAlign = element.dataset.buttonAlign;
88
+ if (element.dataset.colorPreset) options.colorPreset = element.dataset.colorPreset;
89
+ if (element.dataset.waveformColor) options.waveformColor = parseColorValue(element.dataset.waveformColor);
90
+ if (element.dataset.progressColor) options.progressColor = parseColorValue(element.dataset.progressColor);
91
+ if (element.dataset.buttonColor) options.buttonColor = element.dataset.buttonColor;
92
+ if (element.dataset.buttonHoverColor) options.buttonHoverColor = element.dataset.buttonHoverColor;
93
+ if (element.dataset.textColor) options.textColor = element.dataset.textColor;
94
+ if (element.dataset.textSecondaryColor) options.textSecondaryColor = element.dataset.textSecondaryColor;
95
+ if (element.dataset.backgroundColor) options.backgroundColor = element.dataset.backgroundColor;
96
+ if (element.dataset.borderColor) options.borderColor = element.dataset.borderColor;
97
+ if (element.dataset.color) options.waveformColor = element.dataset.color;
98
+ if (element.dataset.theme) options.colorPreset = element.dataset.theme;
99
+ setBool("autoplay");
100
+ setBool("showControls");
101
+ setBool("showInfo");
102
+ setBool("showTime");
103
+ setBool("showHoverTime");
104
+ setBool("showBPM", "showBpm");
105
+ setBool("singlePlay");
106
+ setBool("playOnSeek");
107
+ if (element.dataset.title) options.title = element.dataset.title;
108
+ if (element.dataset.subtitle) options.subtitle = element.dataset.subtitle;
109
+ if (element.dataset.album) options.album = element.dataset.album;
110
+ if (element.dataset.artwork) options.artwork = element.dataset.artwork;
111
+ if (element.dataset.waveform) options.waveform = element.dataset.waveform;
112
+ setJson("markers");
113
+ setNum("playbackRate", "playbackRate", true);
114
+ setBool("showPlaybackSpeed");
115
+ setJson("playbackRates");
116
+ setBool("enableMediaSession");
117
+ setBool("showMarkers");
118
+ setBool("accessibleSeek");
119
+ if (element.dataset.seekLabel) options.seekLabel = element.dataset.seekLabel;
120
+ if (element.dataset.errorText) options.errorText = element.dataset.errorText;
121
+ if (element.dataset.playIcon) options.playIcon = element.dataset.playIcon;
122
+ if (element.dataset.pauseIcon) options.pauseIcon = element.dataset.pauseIcon;
123
+ return options;
124
+ }
125
+ function formatTime(seconds) {
126
+ if (!seconds || isNaN(seconds) || seconds < 0) return "0:00";
127
+ const hrs = Math.floor(seconds / 3600);
128
+ const mins = Math.floor(seconds % 3600 / 60);
129
+ const secs = Math.floor(seconds % 60);
130
+ if (hrs > 0) {
131
+ return `${hrs}:${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
132
+ }
133
+ return `${mins}:${secs.toString().padStart(2, "0")}`;
134
+ }
135
+ var idCounter = 0;
136
+ function generateId(url) {
137
+ const str = url || "audio";
138
+ let hash = 5381;
139
+ for (let i = 0; i < str.length; i++) {
140
+ hash = (hash << 5) + hash + str.charCodeAt(i) | 0;
141
+ }
142
+ return `wp_${(hash >>> 0).toString(36)}_${(idCounter++).toString(36)}`;
143
+ }
144
+ function extractTitleFromUrl(url) {
145
+ if (!url) return "Audio";
146
+ const parts = url.split("/");
147
+ const filename = parts[parts.length - 1];
148
+ const name = filename.split(".")[0];
149
+ return name.replace(/[-_]/g, " ").replace(/\b\w/g, (l) => l.toUpperCase());
150
+ }
151
+ function perceivedBrightness(color) {
152
+ const rgb = typeof color === "string" ? color.match(/\d+/g) : null;
153
+ if (!rgb || rgb.length < 3) return null;
154
+ const [r, g, b] = rgb.map(Number);
155
+ return (r * 299 + g * 587 + b * 114) / 1e3;
156
+ }
157
+ function mergeOptions(...sources) {
158
+ const result = {};
159
+ for (const source of sources) {
160
+ for (const key in source) {
161
+ if (source[key] !== null && source[key] !== void 0) {
162
+ result[key] = source[key];
163
+ }
164
+ }
165
+ }
166
+ return result;
167
+ }
168
+ function debounce(func, wait) {
169
+ let timeout;
170
+ return function executedFunction(...args) {
171
+ const later = () => {
172
+ clearTimeout(timeout);
173
+ func(...args);
174
+ };
175
+ clearTimeout(timeout);
176
+ timeout = setTimeout(later, wait);
177
+ };
178
+ }
179
+ function resampleData(data, targetLength) {
180
+ if (data.length === targetLength) return data;
181
+ if (data.length === 0 || targetLength === 0) return [];
182
+ const result = [];
183
+ if (targetLength > data.length) {
184
+ const ratio = (data.length - 1) / (targetLength - 1);
185
+ for (let i = 0; i < targetLength; i++) {
186
+ const index = i * ratio;
187
+ const lower = Math.floor(index);
188
+ const upper = Math.ceil(index);
189
+ const fraction = index - lower;
190
+ if (upper >= data.length) {
191
+ result.push(data[data.length - 1]);
192
+ } else if (lower === upper) {
193
+ result.push(data[lower]);
194
+ } else {
195
+ const value = data[lower] * (1 - fraction) + data[upper] * fraction;
196
+ result.push(value);
197
+ }
198
+ }
199
+ } else {
200
+ const bucketSize = data.length / targetLength;
201
+ for (let i = 0; i < targetLength; i++) {
202
+ const start = Math.floor(i * bucketSize);
203
+ const end = Math.floor((i + 1) * bucketSize);
204
+ let max = 0;
205
+ let count = 0;
206
+ for (let j = start; j <= end && j < data.length; j++) {
207
+ if (data[j] > max) {
208
+ max = data[j];
209
+ }
210
+ count++;
211
+ }
212
+ if (count === 0) {
213
+ const nearestIndex = Math.min(Math.round(i * bucketSize), data.length - 1);
214
+ max = data[nearestIndex];
215
+ }
216
+ result.push(max);
217
+ }
218
+ }
219
+ return result;
220
+ }
221
+
222
+ // src/js/drawing.js
223
+ function makeFill(ctx, value, height) {
224
+ if (!Array.isArray(value)) return value;
225
+ if (value.length === 1) return value[0];
226
+ const grad = ctx.createLinearGradient(0, 0, 0, height);
227
+ value.forEach((c, i) => grad.addColorStop(i / (value.length - 1), c));
228
+ return grad;
229
+ }
230
+ function fillBar(ctx, x, y, w, h, radii) {
231
+ const any = Array.isArray(radii) ? radii.some((r) => r > 0) : radii > 0;
232
+ if (any && typeof ctx.roundRect === "function") {
233
+ const max = Math.min(w / 2, Math.abs(h) / 2);
234
+ const clampR = (r) => clamp(r, 0, max);
235
+ ctx.beginPath();
236
+ ctx.roundRect(x, y, w, h, Array.isArray(radii) ? radii.map(clampR) : clampR(radii));
237
+ ctx.fill();
238
+ } else {
239
+ ctx.fillRect(x, y, w, h);
240
+ }
241
+ }
242
+ function barRadiusPx(options, dpr) {
243
+ return (options.barRadius || 0) * dpr;
244
+ }
245
+ function barRadii(options, dpr) {
246
+ const r = barRadiusPx(options, dpr);
247
+ return [r, r, 0, 0];
248
+ }
249
+ function capsulePath(ctx, startX, endX, centerY, barHeight) {
250
+ const r = barHeight / 2;
251
+ ctx.beginPath();
252
+ ctx.moveTo(startX, centerY - r);
253
+ ctx.lineTo(endX - r, centerY - r);
254
+ ctx.arc(endX - r, centerY, r, -Math.PI / 2, Math.PI / 2);
255
+ ctx.lineTo(startX, centerY + r);
256
+ ctx.arc(startX, centerY, r, Math.PI / 2, -Math.PI / 2);
257
+ ctx.closePath();
258
+ }
259
+ function drawBars(ctx, canvas, peaks, progress, options) {
260
+ const dpr = window.devicePixelRatio || 1;
261
+ const barWidth = options.barWidth * dpr;
262
+ const barSpacing = options.barSpacing * dpr;
263
+ const barCount = Math.floor(canvas.width / (barWidth + barSpacing));
264
+ const resampledPeaks = resampleData(peaks, barCount);
265
+ const height = canvas.height;
266
+ const progressWidth = progress * canvas.width;
267
+ const radii = barRadii(options, dpr);
268
+ const baseFill = makeFill(ctx, options.color, height);
269
+ const progFill = makeFill(ctx, options.progressColor, height);
270
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
271
+ ctx.fillStyle = baseFill;
272
+ for (let i = 0; i < resampledPeaks.length; i++) {
273
+ const x = i * (barWidth + barSpacing);
274
+ if (x + barWidth > canvas.width) break;
275
+ const peakHeight = resampledPeaks[i] * height * 0.9;
276
+ const y = height - peakHeight;
277
+ fillBar(ctx, x, y, barWidth, peakHeight, radii);
278
+ }
279
+ ctx.save();
280
+ ctx.beginPath();
281
+ ctx.rect(0, 0, progressWidth, height);
282
+ ctx.clip();
283
+ ctx.fillStyle = progFill;
284
+ for (let i = 0; i < resampledPeaks.length; i++) {
285
+ const x = i * (barWidth + barSpacing);
286
+ if (x > progressWidth) break;
287
+ const peakHeight = resampledPeaks[i] * height * 0.9;
288
+ const y = height - peakHeight;
289
+ fillBar(ctx, x, y, barWidth, peakHeight, radii);
290
+ }
291
+ ctx.restore();
292
+ }
293
+ function drawMirror(ctx, canvas, peaks, progress, options) {
294
+ const dpr = window.devicePixelRatio || 1;
295
+ const barWidth = options.barWidth * dpr;
296
+ const barSpacing = options.barSpacing * dpr;
297
+ const barCount = Math.floor(canvas.width / (barWidth + barSpacing));
298
+ const resampledPeaks = resampleData(peaks, barCount);
299
+ const height = canvas.height;
300
+ const centerY = height / 2;
301
+ const progressWidth = progress * canvas.width;
302
+ const r = barRadiusPx(options, dpr);
303
+ const topRadii = [r, r, 0, 0];
304
+ const botRadii = [0, 0, r, r];
305
+ const baseFill = makeFill(ctx, options.color, height);
306
+ const progFill = makeFill(ctx, options.progressColor, height);
307
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
308
+ ctx.fillStyle = baseFill;
309
+ for (let i = 0; i < resampledPeaks.length; i++) {
310
+ const x = i * (barWidth + barSpacing);
311
+ if (x + barWidth > canvas.width) break;
312
+ const peakHeight = resampledPeaks[i] * height * 0.45;
313
+ fillBar(ctx, x, centerY - peakHeight, barWidth, peakHeight, topRadii);
314
+ fillBar(ctx, x, centerY, barWidth, peakHeight, botRadii);
315
+ }
316
+ ctx.save();
317
+ ctx.beginPath();
318
+ ctx.rect(0, 0, progressWidth, height);
319
+ ctx.clip();
320
+ ctx.fillStyle = progFill;
321
+ for (let i = 0; i < resampledPeaks.length; i++) {
322
+ const x = i * (barWidth + barSpacing);
323
+ if (x > progressWidth) break;
324
+ const peakHeight = resampledPeaks[i] * height * 0.45;
325
+ fillBar(ctx, x, centerY - peakHeight, barWidth, peakHeight, topRadii);
326
+ fillBar(ctx, x, centerY, barWidth, peakHeight, botRadii);
327
+ }
328
+ ctx.restore();
329
+ }
330
+ function drawLine(ctx, canvas, peaks, progress, options) {
331
+ const width = canvas.width;
332
+ const height = canvas.height;
333
+ const centerY = height / 2;
334
+ const amplitude = height * 0.35;
335
+ ctx.clearRect(0, 0, width, height);
336
+ const drawCurve = (color, lineWidth, endProgress = 1, addGlow = false) => {
337
+ if (addGlow) {
338
+ ctx.shadowBlur = 12;
339
+ ctx.shadowColor = color;
340
+ }
341
+ ctx.strokeStyle = color;
342
+ ctx.lineWidth = lineWidth;
343
+ ctx.lineCap = "round";
344
+ ctx.lineJoin = "round";
345
+ ctx.beginPath();
346
+ ctx.moveTo(0, centerY);
347
+ const points = [];
348
+ const samples = Math.floor(peaks.length * endProgress);
349
+ for (let i = 0; i < samples; i++) {
350
+ const x = i / (peaks.length - 1) * width;
351
+ const peakValue = peaks[i];
352
+ const waveOffset = Math.sin(i * 0.1) * peakValue;
353
+ const y = centerY + waveOffset * amplitude;
354
+ points.push({ x, y });
355
+ }
356
+ for (let i = 0; i < points.length - 1; i++) {
357
+ const cp1x = points[i].x + (points[i + 1].x - points[i].x) * 0.5;
358
+ const cp1y = points[i].y;
359
+ const cp2x = points[i + 1].x - (points[i + 1].x - points[i].x) * 0.5;
360
+ const cp2y = points[i + 1].y;
361
+ ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, points[i + 1].x, points[i + 1].y);
362
+ }
363
+ ctx.stroke();
364
+ if (addGlow) {
365
+ ctx.shadowBlur = 0;
366
+ }
367
+ };
368
+ ctx.strokeStyle = "rgba(255, 255, 255, 0.03)";
369
+ ctx.lineWidth = 0.5;
370
+ ctx.beginPath();
371
+ ctx.moveTo(0, centerY);
372
+ ctx.lineTo(width, centerY);
373
+ ctx.stroke();
374
+ for (let i = 0; i <= 10; i++) {
375
+ const x = width / 10 * i;
376
+ ctx.beginPath();
377
+ ctx.moveTo(x, 0);
378
+ ctx.lineTo(x, height);
379
+ ctx.stroke();
380
+ }
381
+ drawCurve(options.color, 2, 1, false);
382
+ if (progress > 0) {
383
+ drawCurve(options.progressColor, 3, progress, true);
384
+ }
385
+ }
386
+ function drawBlocks(ctx, canvas, peaks, progress, options) {
387
+ const dpr = window.devicePixelRatio || 1;
388
+ const barWidth = (options.barWidth || 3) * dpr;
389
+ const barSpacing = (options.barSpacing || 1) * dpr;
390
+ const barCount = Math.floor(canvas.width / (barWidth + barSpacing));
391
+ const resampledPeaks = resampleData(peaks, barCount);
392
+ const height = canvas.height;
393
+ const blockSize = 4 * dpr;
394
+ const blockGap = 2 * dpr;
395
+ const progressWidth = progress * canvas.width;
396
+ const centerY = height / 2;
397
+ const baseFill = makeFill(ctx, options.color, height);
398
+ const progFill = makeFill(ctx, options.progressColor, height);
399
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
400
+ for (let i = 0; i < resampledPeaks.length; i++) {
401
+ const x = i * (barWidth + barSpacing);
402
+ if (x + barWidth > canvas.width) break;
403
+ const peakHeight = resampledPeaks[i] * height * 0.9;
404
+ const blockCount = Math.floor(peakHeight / (blockSize + blockGap));
405
+ ctx.fillStyle = x < progressWidth ? progFill : baseFill;
406
+ for (let j = 0; j < blockCount; j++) {
407
+ const blockOffset = j * (blockSize + blockGap);
408
+ ctx.fillRect(x, centerY - blockOffset - blockSize, barWidth, blockSize);
409
+ if (j > 0) {
410
+ ctx.fillRect(x, centerY + blockOffset, barWidth, blockSize);
411
+ }
412
+ }
413
+ }
414
+ }
415
+ function drawDots(ctx, canvas, peaks, progress, options) {
416
+ const dpr = window.devicePixelRatio || 1;
417
+ const barWidth = (options.barWidth || 2) * dpr;
418
+ const barSpacing = (options.barSpacing || 3) * dpr;
419
+ const barCount = Math.floor(canvas.width / (barWidth + barSpacing));
420
+ const resampledPeaks = resampleData(peaks, barCount);
421
+ const height = canvas.height;
422
+ const dotRadius = Math.max(1.5 * dpr, barWidth / 2);
423
+ const progressWidth = progress * canvas.width;
424
+ const centerY = height / 2;
425
+ const baseFill = makeFill(ctx, options.color, height);
426
+ const progFill = makeFill(ctx, options.progressColor, height);
427
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
428
+ for (let i = 0; i < resampledPeaks.length; i++) {
429
+ const x = i * (barWidth + barSpacing) + barWidth / 2;
430
+ if (x > canvas.width) break;
431
+ const peakHeight = resampledPeaks[i] * height * 0.9;
432
+ ctx.fillStyle = x < progressWidth ? progFill : baseFill;
433
+ ctx.beginPath();
434
+ ctx.arc(x, centerY - peakHeight / 2, dotRadius, 0, Math.PI * 2);
435
+ ctx.fill();
436
+ ctx.beginPath();
437
+ ctx.arc(x, centerY + peakHeight / 2, dotRadius, 0, Math.PI * 2);
438
+ ctx.fill();
439
+ }
440
+ }
441
+ function drawSeekbar(ctx, canvas, peaks, progress, options) {
442
+ const width = canvas.width;
443
+ const height = canvas.height;
444
+ const centerY = height / 2;
445
+ const barHeight = 4;
446
+ const borderRadius = barHeight / 2;
447
+ ctx.clearRect(0, 0, width, height);
448
+ ctx.fillStyle = options.color || "rgba(255, 255, 255, 0.2)";
449
+ capsulePath(ctx, borderRadius, width, centerY, barHeight);
450
+ ctx.fill();
451
+ if (progress > 0) {
452
+ const progressWidth = Math.max(borderRadius * 2, progress * width);
453
+ ctx.shadowBlur = 8;
454
+ ctx.shadowColor = options.progressColor;
455
+ ctx.fillStyle = options.progressColor || "rgba(255, 255, 255, 0.9)";
456
+ capsulePath(ctx, borderRadius, progressWidth, centerY, barHeight);
457
+ ctx.fill();
458
+ ctx.shadowBlur = 0;
459
+ const handleRadius = 8;
460
+ const handleX = progressWidth;
461
+ ctx.shadowBlur = 4;
462
+ ctx.shadowColor = "rgba(0, 0, 0, 0.3)";
463
+ ctx.shadowOffsetY = 2;
464
+ ctx.fillStyle = "#ffffff";
465
+ ctx.beginPath();
466
+ ctx.arc(handleX, centerY, handleRadius, 0, Math.PI * 2);
467
+ ctx.fill();
468
+ ctx.shadowBlur = 0;
469
+ ctx.shadowOffsetY = 0;
470
+ ctx.fillStyle = options.progressColor || "rgba(255, 255, 255, 0.9)";
471
+ ctx.beginPath();
472
+ ctx.arc(handleX, centerY, handleRadius * 0.4, 0, Math.PI * 2);
473
+ ctx.fill();
474
+ }
475
+ }
476
+ var DRAWING_STYLES = {
477
+ "bars": drawBars,
478
+ // Classic vertical bars
479
+ "bar": drawBars,
480
+ "mirror": drawMirror,
481
+ // SoundCloud-style symmetrical
482
+ "line": drawLine,
483
+ // Smooth oscilloscope wave
484
+ "blocks": drawBlocks,
485
+ // LED meter segmented
486
+ "block": drawBlocks,
487
+ "dots": drawDots,
488
+ // Circular points
489
+ "dot": drawDots,
490
+ "seekbar": drawSeekbar
491
+ // Simple progress bar (no waveform)
492
+ };
493
+ function draw(ctx, canvas, peaks, progress, options) {
494
+ const drawFunc = DRAWING_STYLES[options.waveformStyle] || drawBars;
495
+ drawFunc(ctx, canvas, peaks, progress, options);
496
+ }
497
+
498
+ // src/js/bpm.js
499
+ function detectBPM(buffer) {
500
+ try {
501
+ const channelData = buffer.getChannelData(0);
502
+ const sampleRate = buffer.sampleRate;
503
+ const onsets = detectOnsets(channelData, sampleRate);
504
+ if (onsets.length < 2) return 120;
505
+ const intervals = [];
506
+ for (let i = 1; i < onsets.length; i++) {
507
+ intervals.push((onsets[i] - onsets[i - 1]) / sampleRate);
508
+ }
509
+ const tempoGroups = {};
510
+ intervals.forEach((interval) => {
511
+ const tempo = 60 / interval;
512
+ const bucket = Math.round(tempo / 3) * 3;
513
+ if (bucket > 60 && bucket < 200) {
514
+ tempoGroups[bucket] = (tempoGroups[bucket] || 0) + 1;
515
+ }
516
+ });
517
+ let maxCount = 0;
518
+ let detectedBPM = 120;
519
+ for (const [tempo, count] of Object.entries(tempoGroups)) {
520
+ if (count > maxCount) {
521
+ maxCount = count;
522
+ detectedBPM = parseInt(tempo);
523
+ }
524
+ }
525
+ if (detectedBPM < 70 && tempoGroups[detectedBPM * 2]) {
526
+ detectedBPM *= 2;
527
+ } else if (detectedBPM > 160 && tempoGroups[Math.round(detectedBPM / 2)]) {
528
+ detectedBPM = Math.round(detectedBPM / 2);
529
+ }
530
+ return detectedBPM - 1;
531
+ } catch (e) {
532
+ console.warn("[WaveformPlayer] BPM detection failed:", e);
533
+ return null;
534
+ }
535
+ }
536
+ function detectOnsets(channelData, sampleRate) {
537
+ const windowSize = 2048;
538
+ const hopSize = windowSize / 2;
539
+ const onsets = [];
540
+ let previousEnergy = 0;
541
+ for (let i = 0; i < channelData.length - windowSize; i += hopSize) {
542
+ let energy = 0;
543
+ for (let j = i; j < i + windowSize; j++) {
544
+ energy += channelData[j] * channelData[j];
545
+ }
546
+ energy = energy / windowSize;
547
+ const energyDiff = energy - previousEnergy;
548
+ const threshold = previousEnergy * 1.8 + 0.01;
549
+ if (energyDiff > threshold && energy > 0.01) {
550
+ const lastOnset = onsets[onsets.length - 1] || 0;
551
+ const minDistance = sampleRate * 0.15;
552
+ if (i - lastOnset > minDistance) {
553
+ onsets.push(i);
554
+ }
555
+ }
556
+ previousEnergy = energy * 0.8 + previousEnergy * 0.2;
557
+ }
558
+ return onsets;
559
+ }
560
+
561
+ // src/js/audio.js
562
+ function extractPeaks(buffer, samples = 200) {
563
+ const sampleSize = buffer.length / samples;
564
+ const sampleStep = ~~(sampleSize / 10) || 1;
565
+ const channels = buffer.numberOfChannels;
566
+ const peaks = [];
567
+ for (let c = 0; c < channels; c++) {
568
+ const chan = buffer.getChannelData(c);
569
+ for (let i = 0; i < samples; i++) {
570
+ const start = ~~(i * sampleSize);
571
+ const end = ~~(start + sampleSize);
572
+ let min = 0;
573
+ let max = 0;
574
+ for (let j = start; j < end; j += sampleStep) {
575
+ const value = chan[j];
576
+ if (value > max) max = value;
577
+ if (value < min) min = value;
578
+ }
579
+ const peak = Math.max(Math.abs(max), Math.abs(min));
580
+ if (c === 0 || peak > peaks[i]) {
581
+ peaks[i] = peak;
582
+ }
583
+ }
584
+ }
585
+ const maxPeak = Math.max(...peaks);
586
+ return maxPeak > 0 ? peaks.map((peak) => peak / maxPeak) : peaks;
587
+ }
588
+ async function generateWaveform(url, samples = 200, shouldDetectBPM = false) {
589
+ let audioContext;
590
+ try {
591
+ const AudioCtx = window.AudioContext || /** @type {any} */
592
+ window.webkitAudioContext;
593
+ audioContext = new AudioCtx();
594
+ const response = await fetch(url);
595
+ const arrayBuffer = await response.arrayBuffer();
596
+ const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
597
+ let peaks = extractPeaks(audioBuffer, samples);
598
+ peaks = normalizePeaks(peaks);
599
+ let bpm = null;
600
+ if (shouldDetectBPM) {
601
+ bpm = detectBPM(audioBuffer);
602
+ }
603
+ return { peaks, bpm };
604
+ } finally {
605
+ if (audioContext) audioContext.close();
606
+ }
607
+ }
608
+ function generatePlaceholderWaveform(samples = 200) {
609
+ const data = [];
610
+ for (let i = 0; i < samples; i++) {
611
+ const base = Math.random() * 0.5 + 0.3;
612
+ const variation = Math.sin(i / samples * Math.PI * 4) * 0.2;
613
+ data.push(clamp(base + variation, 0.1, 1));
614
+ }
615
+ return data;
616
+ }
617
+ function normalizePeaks(peaks, targetMax = 0.95) {
618
+ const maxPeak = Math.max(...peaks);
619
+ if (maxPeak === 0 || maxPeak > targetMax) return peaks;
620
+ const scaleFactor = targetMax / maxPeak;
621
+ return peaks.map((peak) => peak * scaleFactor);
622
+ }
623
+
624
+ // src/js/themes.js
625
+ function hasThemeHint(scheme) {
626
+ const root = document.documentElement;
627
+ const body = document.body;
628
+ 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;
629
+ }
630
+ function detectColorScheme() {
631
+ if (hasThemeHint("dark")) return "dark";
632
+ if (hasThemeHint("light")) return "light";
633
+ try {
634
+ const bodyBg = getComputedStyle(document.body).backgroundColor;
635
+ const brightness = perceivedBrightness(bodyBg);
636
+ if (brightness !== null) {
637
+ if (brightness > 128) return "light";
638
+ if (brightness < 128) return "dark";
639
+ }
640
+ } catch (e) {
641
+ }
642
+ if (window.matchMedia) {
643
+ if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
644
+ return "dark";
645
+ }
646
+ if (window.matchMedia("(prefers-color-scheme: light)").matches) {
647
+ return "light";
648
+ }
649
+ }
650
+ return "dark";
651
+ }
652
+ var COLOR_PRESETS = {
653
+ dark: {
654
+ waveformColor: "rgba(255, 255, 255, 0.3)",
655
+ progressColor: "rgba(255, 255, 255, 0.9)",
656
+ buttonColor: "rgba(255, 255, 255, 0.9)",
657
+ buttonHoverColor: "rgba(255, 255, 255, 1)",
658
+ textColor: "#ffffff",
659
+ textSecondaryColor: "rgba(255, 255, 255, 0.6)",
660
+ backgroundColor: "rgba(255, 255, 255, 0.03)",
661
+ borderColor: "rgba(255, 255, 255, 0.1)"
662
+ },
663
+ light: {
664
+ waveformColor: "rgba(0, 0, 0, 0.2)",
665
+ progressColor: "rgba(0, 0, 0, 0.8)",
666
+ buttonColor: "rgba(0, 0, 0, 0.8)",
667
+ buttonHoverColor: "rgba(0, 0, 0, 0.9)",
668
+ textColor: "#333333",
669
+ textSecondaryColor: "rgba(0, 0, 0, 0.6)",
670
+ backgroundColor: "rgba(0, 0, 0, 0.02)",
671
+ borderColor: "rgba(0, 0, 0, 0.1)"
672
+ }
673
+ };
674
+ function getColorPreset(presetName) {
675
+ if (presetName && COLOR_PRESETS[presetName]) {
676
+ return COLOR_PRESETS[presetName];
677
+ }
678
+ const detected = detectColorScheme();
679
+ return COLOR_PRESETS[detected];
680
+ }
681
+ var DEFAULT_OPTIONS = {
682
+ // Core settings
683
+ url: "",
684
+ height: 60,
685
+ samples: 200,
686
+ preload: "metadata",
687
+ // Audio mode — 'self' = player owns the <audio> element (default, current
688
+ // behavior). 'external' = player is a visualization-only surface; no audio
689
+ // element is created, play() dispatches `waveformplayer:request-play`
690
+ // instead of calling audio.play(), and setPlayingState/setProgress are
691
+ // expected to be driven by an external controller (e.g. WaveformBar).
692
+ audioMode: "self",
693
+ // Playback
694
+ playbackRate: 1,
695
+ showPlaybackSpeed: false,
696
+ playbackRates: [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2],
697
+ // Layout Options
698
+ buttonAlign: "auto",
699
+ // Default waveform style
700
+ waveformStyle: "mirror",
701
+ barWidth: 2,
702
+ barSpacing: 0,
703
+ // Rounded bar caps (px). 0 = square (default). Applies to bars/mirror.
704
+ barRadius: 0,
705
+ // Color preset: null = auto-detect, 'dark' = force dark, 'light' = force light
706
+ colorPreset: null,
707
+ // Individual color overrides (null means use preset)
708
+ waveformColor: null,
709
+ progressColor: null,
710
+ buttonColor: null,
711
+ buttonHoverColor: null,
712
+ textColor: null,
713
+ textSecondaryColor: null,
714
+ backgroundColor: null,
715
+ borderColor: null,
716
+ // Features
717
+ autoplay: false,
718
+ showControls: true,
719
+ showInfo: true,
720
+ showTime: true,
721
+ showHoverTime: false,
722
+ showBPM: false,
723
+ singlePlay: true,
724
+ playOnSeek: true,
725
+ enableMediaSession: true,
726
+ // Markers
727
+ markers: [],
728
+ showMarkers: true,
729
+ // Accessibility — expose the waveform as a keyboard-operable slider
730
+ // (role="slider" + ARIA value attributes + arrow/page/home/end seeking).
731
+ // seekLabel sets the slider's accessible name; when null it falls back
732
+ // to the track title, then 'Seek'.
733
+ accessibleSeek: true,
734
+ seekLabel: null,
735
+ // Content
736
+ title: null,
737
+ subtitle: null,
738
+ artwork: null,
739
+ album: "",
740
+ // Message shown in the error state when audio fails to load.
741
+ errorText: "Unable to load audio",
742
+ // Icons (SVG)
743
+ playIcon: '<svg viewBox="0 0 24 24" width="16" height="16"><path d="M8 5v14l11-7z"/></svg>',
744
+ pauseIcon: '<svg viewBox="0 0 24 24" width="16" height="16"><path d="M6 4h4v16H6zM14 4h4v16h-4z"/></svg>',
745
+ // Callbacks
746
+ onLoad: null,
747
+ onPlay: null,
748
+ onPause: null,
749
+ onEnd: null,
750
+ onError: null,
751
+ onTimeUpdate: null
752
+ };
753
+ var STYLE_DEFAULTS = {
754
+ bars: { barWidth: 3, barSpacing: 1 },
755
+ mirror: { barWidth: 2, barSpacing: 0 },
756
+ line: { barWidth: 2, barSpacing: 0 },
757
+ blocks: { barWidth: 4, barSpacing: 2 },
758
+ dots: { barWidth: 3, barSpacing: 3 },
759
+ seekbar: { barWidth: 1, barSpacing: 0 }
760
+ };
761
+
762
+ // src/js/core.js
763
+ var SEEK_STEP_SECONDS = 5;
764
+ var SEEK_PAGE_SECONDS = 10;
765
+ var WaveformPlayer = class _WaveformPlayer {
766
+ /** @type {Map<string, WaveformPlayer>} */
767
+ static instances = /* @__PURE__ */ new Map();
768
+ /** @type {WaveformPlayer|null} */
769
+ static currentlyPlaying = null;
770
+ /**
771
+ * Create a new WaveformPlayer instance.
772
+ *
773
+ * Resolves the container, merges options (defaults < `data-*` attributes <
774
+ * constructor options), applies the colour preset and style-specific
775
+ * defaults, registers the instance in the static map, and kicks off
776
+ * {@link WaveformPlayer#init}. A `waveformplayer:ready` event is dispatched
777
+ * ~100ms later, once initialization has settled.
778
+ *
779
+ * @param {string|HTMLElement} container - Container element, or a CSS
780
+ * selector resolved with `document.querySelector`.
781
+ * @param {Object} [options={}] - Player options. Accepts the shorthand
782
+ * aliases `style` (→ `waveformStyle`) and `src` (→ `url`); the canonical
783
+ * names win if both are supplied.
784
+ * @throws {Error} If the container element cannot be found.
785
+ * @fires WaveformPlayer#waveformplayer:ready
786
+ */
787
+ constructor(container, options = {}) {
788
+ this.container = typeof container === "string" ? document.querySelector(container) : container;
789
+ if (!this.container) {
790
+ throw new Error("[WaveformPlayer] Container element not found");
791
+ }
792
+ const dataOptions = parseDataAttributes(this.container);
793
+ const userOptions = { ...options };
794
+ if (userOptions.style && !userOptions.waveformStyle) userOptions.waveformStyle = userOptions.style;
795
+ if (userOptions.src && !userOptions.url) userOptions.url = userOptions.src;
796
+ this.options = mergeOptions(DEFAULT_OPTIONS, dataOptions, userOptions);
797
+ const preset = getColorPreset(this.options.colorPreset);
798
+ for (const [key, value] of Object.entries(preset)) {
799
+ if (this.options[key] === null || this.options[key] === void 0) {
800
+ this.options[key] = value;
801
+ }
802
+ }
803
+ const styleDefaults = STYLE_DEFAULTS[this.options.waveformStyle];
804
+ if (styleDefaults) {
805
+ if (dataOptions.barWidth === void 0 && options.barWidth === void 0) {
806
+ this.options.barWidth = styleDefaults.barWidth;
807
+ }
808
+ if (dataOptions.barSpacing === void 0 && options.barSpacing === void 0) {
809
+ this.options.barSpacing = styleDefaults.barSpacing;
810
+ }
811
+ }
812
+ this.audio = null;
813
+ this.canvas = null;
814
+ this.ctx = null;
815
+ this.waveformData = [];
816
+ this.progress = 0;
817
+ this.isPlaying = false;
818
+ this.isLoading = false;
819
+ this.hasError = false;
820
+ this.updateTimer = null;
821
+ this.resizeObserver = null;
822
+ this._ac = new AbortController();
823
+ this.id = this.container.id || generateId(this.options.url);
824
+ _WaveformPlayer.instances.set(this.id, this);
825
+ this.init();
826
+ setTimeout(() => {
827
+ this._emit("waveformplayer:ready", { player: this, url: this.options.url });
828
+ }, 100);
829
+ }
830
+ /**
831
+ * Build and dispatch a bubbling `waveformplayer:*` CustomEvent on the
832
+ * container, returning the event so cancelable (request-*) events can have
833
+ * their `defaultPrevented` checked. Single source of truth for the event
834
+ * shape — every player event bubbles and carries the supplied detail.
835
+ * @param {string} type - Full event type, e.g. `'waveformplayer:play'`.
836
+ * @param {Object} detail - Event detail payload.
837
+ * @param {boolean} [cancelable=false] - Whether the event is cancelable.
838
+ * @returns {CustomEvent} The dispatched event.
839
+ * @private
840
+ */
841
+ _emit(type, detail, cancelable = false) {
842
+ const event = new CustomEvent(type, { bubbles: true, cancelable, detail });
843
+ this.container.dispatchEvent(event);
844
+ return event;
845
+ }
846
+ /**
847
+ * External-mode seek request: dispatch a cancelable
848
+ * `waveformplayer:request-seek` and, unless the controller calls
849
+ * `preventDefault()`, optimistically advance the local progress overlay so
850
+ * the canvas repaints at once. Shared by the keyboard slider and canvas click.
851
+ * @param {number} percent - Target position as a 0..1 fraction.
852
+ * @private
853
+ * @fires WaveformPlayer#waveformplayer:request-seek
854
+ */
855
+ _requestSeek(percent) {
856
+ const evt = this._emit("waveformplayer:request-seek", { ...this._buildTrackDetail(), percent }, true);
857
+ if (!evt.defaultPrevented) {
858
+ this.progress = percent;
859
+ this.drawWaveform?.();
860
+ }
861
+ }
862
+ // ============================================
863
+ // Initialization
864
+ // ============================================
865
+ /**
866
+ * Initialize the player: build the DOM, create the audio element (self
867
+ * mode only), wire up the feature controls (speed, keyboard, accessible
868
+ * seek), bind events, attach the resize observer, then size the canvas and
869
+ * — if a `url` option was given — load it and optionally autoplay.
870
+ * @private
871
+ */
872
+ init() {
873
+ this.createDOM();
874
+ this.createAudio();
875
+ this.initPlaybackSpeed();
876
+ this.initKeyboardControls();
877
+ this.initSeekControl();
878
+ this.bindEvents();
879
+ this.setupResizeObserver();
880
+ requestAnimationFrame(() => {
881
+ this.resizeCanvas();
882
+ if (this.options.url) {
883
+ this.load(this.options.url).then(() => {
884
+ if (this.options.autoplay) {
885
+ this.play()?.catch(() => {
886
+ });
887
+ }
888
+ }).catch((error) => {
889
+ console.error("[WaveformPlayer] Failed to load audio:", error);
890
+ });
891
+ }
892
+ });
893
+ }
894
+ /**
895
+ * Build the player's DOM tree inside the container and cache element
896
+ * references.
897
+ *
898
+ * Clears the container, resolves button alignment (`auto` → `bottom` for
899
+ * the `bars` style, `center` otherwise), and conditionally renders the play
900
+ * button, info row (artwork/title/subtitle), BPM badge, playback-speed
901
+ * menu, and time display based on the relevant `show*` options. Caches the
902
+ * canvas, controls, and text elements onto `this`, then sizes the canvas.
903
+ * @private
904
+ */
905
+ createDOM() {
906
+ this.container.innerHTML = "";
907
+ this.container.className = "waveform-player";
908
+ let buttonAlign = this.options.buttonAlign;
909
+ if (buttonAlign === "auto") {
910
+ const style = this.options.waveformStyle;
911
+ if (style === "bars") {
912
+ buttonAlign = "bottom";
913
+ } else {
914
+ buttonAlign = "center";
915
+ }
916
+ }
917
+ const buttonHTML = this.options.showControls ? `
918
+ <button class="waveform-btn" aria-label="Play/Pause" style="
919
+ border-color: ${this.options.buttonColor};
920
+ color: ${this.options.buttonColor};
921
+ ">
922
+ <span class="waveform-icon-play">${this.options.playIcon}</span>
923
+ <span class="waveform-icon-pause" style="display:none;">${this.options.pauseIcon}</span>
924
+ </button>
925
+ ` : "";
926
+ const infoHTML = this.options.showInfo ? `
927
+ <div class="waveform-info">
928
+ ${this.options.artwork ? `
929
+ <img class="waveform-artwork" src="${this.options.artwork}" alt="Album artwork" style="
930
+ width: 40px;
931
+ height: 40px;
932
+ border-radius: 4px;
933
+ object-fit: cover;
934
+ flex-shrink: 0;
935
+ ">
936
+ ` : ""}
937
+ <div class="waveform-text">
938
+ <span class="waveform-title" style="color: ${this.options.textColor};"></span>
939
+ ${this.options.subtitle ? `<span class="waveform-subtitle" style="color: ${this.options.textSecondaryColor};">${this.options.subtitle}</span>` : ""}
940
+ </div>
941
+ <div style="display: flex; align-items: center; gap: 1rem;">
942
+ ${this.options.showBPM ? `
943
+ <span class="waveform-bpm" style="color: ${this.options.textSecondaryColor}; display: none;">
944
+ <span class="bpm-value">--</span> BPM
945
+ </span>
946
+ ` : ""}
947
+ ${this.options.showPlaybackSpeed ? `
948
+ <div class="waveform-speed">
949
+ <button class="speed-btn" aria-label="Playback speed">
950
+ <span class="speed-value">1x</span>
951
+ </button>
952
+ <div class="speed-menu" style="display: none;">
953
+ ${this.options.playbackRates.map(
954
+ (rate) => `<button class="speed-option" data-rate="${rate}">${rate}x</button>`
955
+ ).join("")}
956
+ </div>
957
+ </div>
958
+ ` : ""}
959
+ ${this.options.showTime ? `
960
+ <span class="waveform-time" style="color: ${this.options.textSecondaryColor};">
961
+ <span class="time-current">0:00</span> / <span class="time-total">0:00</span>
962
+ </span>
963
+ ` : ""}
964
+ </div>
965
+ </div>
966
+ ` : "";
967
+ this.container.innerHTML = `
968
+ <div class="waveform-player-inner">
969
+ <div class="waveform-body">
970
+ <div class="waveform-track waveform-align-${buttonAlign}">
971
+ ${buttonHTML}
972
+
973
+ <div class="waveform-container">
974
+ <canvas></canvas>
975
+ <div class="waveform-markers"></div>
976
+ <div class="waveform-loading" style="display:none;"></div>
977
+ <div class="waveform-error" style="display:none;" role="alert">
978
+ <span class="waveform-error-text">${escapeHtml(this.options.errorText)}</span>
979
+ </div>
980
+ </div>
981
+ </div>
982
+
983
+ ${infoHTML}
984
+ </div>
985
+ </div>
986
+ `;
987
+ this.playBtn = this.container.querySelector(".waveform-btn");
988
+ this.canvas = this.container.querySelector("canvas");
989
+ this.ctx = this.canvas.getContext("2d");
990
+ this.titleEl = this.container.querySelector(".waveform-title");
991
+ this.subtitleEl = this.container.querySelector(".waveform-subtitle");
992
+ this.artworkEl = this.container.querySelector(".waveform-artwork");
993
+ this.currentTimeEl = this.container.querySelector(".time-current");
994
+ this.totalTimeEl = this.container.querySelector(".time-total");
995
+ this.bpmEl = this.container.querySelector(".waveform-bpm");
996
+ this.bpmValueEl = this.container.querySelector(".bpm-value");
997
+ this.loadingEl = this.container.querySelector(".waveform-loading");
998
+ this.errorEl = this.container.querySelector(".waveform-error");
999
+ this.markersContainer = this.container.querySelector(".waveform-markers");
1000
+ this.speedBtn = this.container.querySelector(".speed-btn");
1001
+ this.speedMenu = this.container.querySelector(".speed-menu");
1002
+ this.resizeCanvas();
1003
+ }
1004
+ /**
1005
+ * Create audio element
1006
+ * @private
1007
+ *
1008
+ * No-op in `audioMode: 'external'` — the player has no audio of its
1009
+ * own; an external controller (e.g. WaveformBar) owns playback and
1010
+ * pushes state in via setPlayingState() / setProgress(). The
1011
+ * `this.audio` field stays null in that mode; downstream code must
1012
+ * null-check it.
1013
+ */
1014
+ createAudio() {
1015
+ if (this.options.audioMode === "external") {
1016
+ this.audio = null;
1017
+ return;
1018
+ }
1019
+ this.audio = new Audio();
1020
+ this.audio.preload = this.options.preload || "metadata";
1021
+ this.audio.crossOrigin = "anonymous";
1022
+ }
1023
+ // ============================================
1024
+ // Feature Initialization
1025
+ // ============================================
1026
+ /**
1027
+ * Apply the configured initial playback rate to the audio element (self
1028
+ * mode only) and, when `showPlaybackSpeed` is enabled, wire up the speed
1029
+ * menu UI via {@link WaveformPlayer#initSpeedControls}.
1030
+ * @private
1031
+ */
1032
+ initPlaybackSpeed() {
1033
+ if (this.audio && this.options.playbackRate && this.options.playbackRate !== 1) {
1034
+ this.audio.playbackRate = this.options.playbackRate;
1035
+ }
1036
+ if (this.options.showPlaybackSpeed) {
1037
+ this.initSpeedControls();
1038
+ }
1039
+ }
1040
+ /**
1041
+ * Wire up the playback-speed menu: toggle it open on the speed button,
1042
+ * close it on any outside click, and apply the chosen rate when a
1043
+ * `.speed-option` is clicked. All listeners are registered against the
1044
+ * instance `AbortController` signal so {@link WaveformPlayer#destroy} tears
1045
+ * them down. No-op if the speed elements are absent.
1046
+ * @private
1047
+ */
1048
+ initSpeedControls() {
1049
+ const speedBtn = this.container.querySelector(".speed-btn");
1050
+ const speedMenu = this.container.querySelector(".speed-menu");
1051
+ if (!speedBtn || !speedMenu) return;
1052
+ speedBtn.addEventListener("click", (e) => {
1053
+ e.stopPropagation();
1054
+ speedMenu.style.display = speedMenu.style.display === "none" ? "block" : "none";
1055
+ }, { signal: this._ac.signal });
1056
+ document.addEventListener("click", () => {
1057
+ speedMenu.style.display = "none";
1058
+ }, { signal: this._ac.signal });
1059
+ speedMenu.addEventListener("click", (e) => {
1060
+ e.stopPropagation();
1061
+ if (e.target.classList.contains("speed-option")) {
1062
+ const rate = parseFloat(e.target.dataset.rate);
1063
+ this.setPlaybackRate(rate);
1064
+ speedMenu.style.display = "none";
1065
+ }
1066
+ }, { signal: this._ac.signal });
1067
+ this.updateSpeedUI();
1068
+ }
1069
+ /**
1070
+ * Enable keyboard transport controls on the container.
1071
+ *
1072
+ * The container is focusable only after it is clicked (it carries
1073
+ * `tabindex="-1"` until then, and clicking steals focus from sibling
1074
+ * players). While focused it handles: digits 0-9 (seek to that tenth of
1075
+ * the track), Space (toggle play), and — in self mode only, since
1076
+ * `this.audio` is null in external mode — arrow keys (seek ±5s, volume
1077
+ * ±0.1) and `m`/`M` (mute). Listeners use the instance abort signal.
1078
+ * @private
1079
+ */
1080
+ initKeyboardControls() {
1081
+ this.container.setAttribute("tabindex", "-1");
1082
+ this.container.addEventListener("click", () => {
1083
+ _WaveformPlayer.getAllInstances().forEach((player) => {
1084
+ if (player !== this) {
1085
+ player.container.setAttribute("tabindex", "-1");
1086
+ }
1087
+ });
1088
+ this.container.setAttribute("tabindex", "0");
1089
+ this.container.focus();
1090
+ }, { signal: this._ac.signal });
1091
+ this.container.addEventListener("keydown", (e) => {
1092
+ if (document.activeElement !== this.container) return;
1093
+ const key = e.key;
1094
+ const hasAudio = !!this.audio;
1095
+ const currentTime = hasAudio ? this.audio.currentTime : 0;
1096
+ if (hasAudio && key >= "0" && key <= "9") {
1097
+ e.preventDefault();
1098
+ this.seekToPercent(parseInt(key) / 10);
1099
+ return;
1100
+ }
1101
+ const actions = {
1102
+ " ": () => this.togglePlay()
1103
+ };
1104
+ if (hasAudio) {
1105
+ actions["ArrowLeft"] = () => this.seekTo(clamp(currentTime - 5, 0, this.audio.duration));
1106
+ actions["ArrowRight"] = () => this.seekTo(clamp(currentTime + 5, 0, this.audio.duration));
1107
+ actions["ArrowUp"] = () => this.setVolume(clamp(this.audio.volume + 0.1));
1108
+ actions["ArrowDown"] = () => this.setVolume(clamp(this.audio.volume - 0.1));
1109
+ actions["m"] = actions["M"] = () => this.audio.muted = !this.audio.muted;
1110
+ }
1111
+ if (actions[key]) {
1112
+ e.preventDefault();
1113
+ actions[key]();
1114
+ }
1115
+ }, { signal: this._ac.signal });
1116
+ }
1117
+ /**
1118
+ * Expose the waveform as an accessible, keyboard-operable slider.
1119
+ *
1120
+ * Adds role="slider" + ARIA value attributes to the waveform surface,
1121
+ * makes it focusable in the tab order, and handles the standard slider
1122
+ * keys (arrows, Page Up/Down, Home/End) to seek. Works in both self and
1123
+ * external audio modes. Opt out with `accessibleSeek: false`.
1124
+ * @private
1125
+ */
1126
+ initSeekControl() {
1127
+ if (!this.options.accessibleSeek) return;
1128
+ this.seekEl = this.container.querySelector(".waveform-container");
1129
+ if (!this.seekEl) return;
1130
+ this.seekEl.setAttribute("role", "slider");
1131
+ this.seekEl.setAttribute("tabindex", "0");
1132
+ this.seekEl.setAttribute("aria-valuemin", "0");
1133
+ this.applySeekLabel();
1134
+ this.updateSeekAccessibility();
1135
+ this.seekEl.addEventListener("keydown", (e) => {
1136
+ const duration = this.getSeekDuration();
1137
+ if (!duration) return;
1138
+ const current = this.getSeekCurrentTime();
1139
+ let target;
1140
+ switch (e.key) {
1141
+ case "ArrowLeft":
1142
+ case "ArrowDown":
1143
+ target = current - SEEK_STEP_SECONDS;
1144
+ break;
1145
+ case "ArrowRight":
1146
+ case "ArrowUp":
1147
+ target = current + SEEK_STEP_SECONDS;
1148
+ break;
1149
+ case "PageDown":
1150
+ target = current - SEEK_PAGE_SECONDS;
1151
+ break;
1152
+ case "PageUp":
1153
+ target = current + SEEK_PAGE_SECONDS;
1154
+ break;
1155
+ case "Home":
1156
+ target = 0;
1157
+ break;
1158
+ case "End":
1159
+ target = duration;
1160
+ break;
1161
+ default:
1162
+ return;
1163
+ }
1164
+ e.preventDefault();
1165
+ e.stopPropagation();
1166
+ this.seekToSeconds(target);
1167
+ }, { signal: this._ac.signal });
1168
+ }
1169
+ /**
1170
+ * Total seekable duration in seconds, regardless of audio mode.
1171
+ * @returns {number}
1172
+ * @private
1173
+ */
1174
+ getSeekDuration() {
1175
+ if (this.options.audioMode === "external") {
1176
+ return this._extDuration || 0;
1177
+ }
1178
+ return this.audio && Number.isFinite(this.audio.duration) ? this.audio.duration : 0;
1179
+ }
1180
+ /**
1181
+ * Current playback position in seconds, regardless of audio mode.
1182
+ * @returns {number}
1183
+ * @private
1184
+ */
1185
+ getSeekCurrentTime() {
1186
+ if (this.options.audioMode === "external") {
1187
+ return this.progress * (this._extDuration || 0);
1188
+ }
1189
+ return this.audio && Number.isFinite(this.audio.currentTime) ? this.audio.currentTime : 0;
1190
+ }
1191
+ /**
1192
+ * Seek the slider to an absolute time, clamped to the track length.
1193
+ *
1194
+ * In self mode this defers to {@link WaveformPlayer#seekTo}. In external
1195
+ * mode it dispatches a cancelable `waveformplayer:request-seek` event with
1196
+ * the target percentage; if the controller doesn't `preventDefault()`, the
1197
+ * local progress/visual is updated optimistically. Either way the ARIA
1198
+ * slider values are refreshed.
1199
+ * @param {number} seconds - Target time in seconds.
1200
+ * @private
1201
+ * @fires WaveformPlayer#waveformplayer:request-seek
1202
+ */
1203
+ seekToSeconds(seconds) {
1204
+ const duration = this.getSeekDuration();
1205
+ if (!duration) return;
1206
+ const clamped = clamp(seconds, 0, duration);
1207
+ if (this.options.audioMode === "external") {
1208
+ this._requestSeek(clamped / duration);
1209
+ this.updateSeekAccessibility();
1210
+ return;
1211
+ }
1212
+ this.seekTo(clamped);
1213
+ }
1214
+ /**
1215
+ * Set the slider's accessible name from `seekLabel`, falling back to the
1216
+ * track title, then a generic 'Seek'. No-op if the slider isn't present.
1217
+ * @param {string} [title=this.options.title] - Track title to fall back to
1218
+ * when `seekLabel` is not set.
1219
+ * @private
1220
+ */
1221
+ applySeekLabel(title = this.options.title) {
1222
+ if (!this.seekEl) return;
1223
+ const label = this.options.seekLabel || title || "Seek";
1224
+ this.seekEl.setAttribute("aria-label", label);
1225
+ }
1226
+ /**
1227
+ * Keep the slider's ARIA value attributes in sync with playback.
1228
+ * @private
1229
+ */
1230
+ updateSeekAccessibility() {
1231
+ if (!this.seekEl) return;
1232
+ const duration = this.getSeekDuration();
1233
+ const current = Math.min(this.getSeekCurrentTime(), duration);
1234
+ this.seekEl.setAttribute("aria-valuemax", String(Math.round(duration)));
1235
+ this.seekEl.setAttribute("aria-valuenow", String(Math.round(current)));
1236
+ this.seekEl.setAttribute(
1237
+ "aria-valuetext",
1238
+ `${formatTime(current)} of ${formatTime(duration)}`
1239
+ );
1240
+ }
1241
+ /**
1242
+ * Initialize Media Session API for system media controls
1243
+ * @private
1244
+ */
1245
+ initMediaSession() {
1246
+ if (!("mediaSession" in navigator) || !this.options.enableMediaSession) return;
1247
+ if (!this.audio) return;
1248
+ navigator.mediaSession.metadata = new MediaMetadata({
1249
+ title: this.options.title || "Unknown Track",
1250
+ artist: this.options.subtitle || "",
1251
+ album: this.options.album || "",
1252
+ artwork: this.options.artwork ? [
1253
+ { src: this.options.artwork, sizes: "512x512", type: "image/jpeg" }
1254
+ ] : []
1255
+ });
1256
+ navigator.mediaSession.setActionHandler("play", () => this.play());
1257
+ navigator.mediaSession.setActionHandler("pause", () => this.pause());
1258
+ navigator.mediaSession.setActionHandler("seekbackward", () => {
1259
+ this.seekTo(clamp(this.audio.currentTime - 10, 0, this.audio.duration));
1260
+ });
1261
+ navigator.mediaSession.setActionHandler("seekforward", () => {
1262
+ this.seekTo(clamp(this.audio.currentTime + 10, 0, this.audio.duration));
1263
+ });
1264
+ navigator.mediaSession.setActionHandler("seekto", (details) => {
1265
+ if (details.seekTime !== null) {
1266
+ this.seekTo(details.seekTime);
1267
+ }
1268
+ });
1269
+ }
1270
+ // ============================================
1271
+ // Event Binding
1272
+ // ============================================
1273
+ /**
1274
+ * Bind the core interaction listeners: play-button click, the `<audio>`
1275
+ * media events (self mode only — external mode is fed state via
1276
+ * {@link WaveformPlayer#setPlayingState}/{@link WaveformPlayer#setProgress}),
1277
+ * canvas click-to-seek, and a debounced window-resize redraw.
1278
+ * @private
1279
+ */
1280
+ bindEvents() {
1281
+ if (this.playBtn) {
1282
+ this.playBtn.addEventListener("click", () => this.togglePlay());
1283
+ }
1284
+ if (this.audio) {
1285
+ this.audio.addEventListener("loadstart", () => this.setLoading(true));
1286
+ this.audio.addEventListener("loadedmetadata", () => this.onMetadataLoaded());
1287
+ this.audio.addEventListener("canplay", () => this.setLoading(false));
1288
+ this.audio.addEventListener("play", () => this.onPlay());
1289
+ this.audio.addEventListener("pause", () => this.onPause());
1290
+ this.audio.addEventListener("ended", () => this.onEnded());
1291
+ this.audio.addEventListener("error", (e) => this.onError(e));
1292
+ }
1293
+ this.canvas.addEventListener("click", (e) => this.handleCanvasClick(e));
1294
+ this.resizeHandler = debounce(() => this.resizeCanvas(), 100);
1295
+ window.addEventListener("resize", this.resizeHandler);
1296
+ }
1297
+ /**
1298
+ * Observe the canvas's parent element for size changes and re-fit the
1299
+ * canvas on each one. No-op where `ResizeObserver` is unavailable.
1300
+ * @private
1301
+ */
1302
+ setupResizeObserver() {
1303
+ if ("ResizeObserver" in window) {
1304
+ this.resizeObserver = new ResizeObserver(() => {
1305
+ this.resizeCanvas();
1306
+ });
1307
+ if (this.canvas?.parentElement) {
1308
+ this.resizeObserver.observe(this.canvas.parentElement);
1309
+ }
1310
+ }
1311
+ }
1312
+ // ============================================
1313
+ // Audio Loading
1314
+ // ============================================
1315
+ /**
1316
+ * Load an audio source: set the title, fetch/generate the waveform peaks,
1317
+ * draw them, render markers, and initialise Media Session.
1318
+ *
1319
+ * In self mode the `<audio>` src is assigned and the method awaits
1320
+ * `loadedmetadata` before proceeding. In external mode there is no audio
1321
+ * element, so the src/metadata step is skipped and only the visualization
1322
+ * is built (duration/time come from the controller via
1323
+ * {@link WaveformPlayer#setProgress}). Peaks come from the `waveform`
1324
+ * option when provided, otherwise they are decoded from the audio; a
1325
+ * decode failure falls back to a placeholder waveform. The `onLoad`
1326
+ * callback fires on success.
1327
+ * @param {string} url - Audio URL.
1328
+ * @returns {Promise<void>} Resolves once loading settles (errors are caught
1329
+ * internally and surfaced through {@link WaveformPlayer#onError}).
1330
+ */
1331
+ async load(url) {
1332
+ try {
1333
+ this.setLoading(true);
1334
+ this.progress = 0;
1335
+ this.hasError = false;
1336
+ if (this.audio) {
1337
+ this.audio.src = url;
1338
+ await new Promise((resolve, reject) => {
1339
+ const metadataHandler = () => {
1340
+ this.audio.removeEventListener("loadedmetadata", metadataHandler);
1341
+ this.audio.removeEventListener("error", errorHandler);
1342
+ resolve();
1343
+ };
1344
+ const errorHandler = (e) => {
1345
+ this.audio.removeEventListener("loadedmetadata", metadataHandler);
1346
+ this.audio.removeEventListener("error", errorHandler);
1347
+ reject(e);
1348
+ };
1349
+ this.audio.addEventListener("loadedmetadata", metadataHandler);
1350
+ this.audio.addEventListener("error", errorHandler);
1351
+ });
1352
+ }
1353
+ const title = this.options.title || extractTitleFromUrl(url);
1354
+ if (this.titleEl) {
1355
+ this.titleEl.textContent = title;
1356
+ }
1357
+ this.applySeekLabel(title);
1358
+ if (this.options.waveform) {
1359
+ this.setWaveformData(this.options.waveform);
1360
+ } else {
1361
+ try {
1362
+ const result = await generateWaveform(url, this.options.samples, this.options.showBPM);
1363
+ this.waveformData = result.peaks;
1364
+ if (result.bpm) {
1365
+ this.detectedBPM = result.bpm;
1366
+ this.updateBPMDisplay();
1367
+ }
1368
+ } catch (error) {
1369
+ console.warn("[WaveformPlayer] Using placeholder waveform:", error);
1370
+ this.waveformData = generatePlaceholderWaveform(this.options.samples);
1371
+ }
1372
+ }
1373
+ this.drawWaveform();
1374
+ this.renderMarkers();
1375
+ this.initMediaSession();
1376
+ if (this.options.onLoad) {
1377
+ this.options.onLoad(this);
1378
+ }
1379
+ } catch (error) {
1380
+ this.onError(error);
1381
+ } finally {
1382
+ this.setLoading(false);
1383
+ }
1384
+ }
1385
+ /**
1386
+ * Swap the player to a new track at runtime.
1387
+ *
1388
+ * Pauses any current playback, fully resets the audio element (self mode),
1389
+ * clears error/marker/progress state, merges the new metadata into
1390
+ * `this.options`, updates the subtitle/artwork DOM, then calls
1391
+ * {@link WaveformPlayer#load}. Auto-plays the new track unless
1392
+ * `options.autoplay === false`.
1393
+ * @param {string} url - Audio URL.
1394
+ * @param {string|null} [title=null] - Track title; keeps the existing
1395
+ * title when null.
1396
+ * @param {string|null} [subtitle=null] - Track subtitle; pass `''` to hide
1397
+ * the subtitle row, or null to keep the existing one.
1398
+ * @param {Object} [options={}] - Additional options to merge (e.g.
1399
+ * `preload`, `artwork`, `markers`, `autoplay`).
1400
+ * @returns {Promise<void>}
1401
+ */
1402
+ async loadTrack(url, title = null, subtitle = null, options = {}) {
1403
+ if (this.isPlaying) {
1404
+ this.pause();
1405
+ }
1406
+ if (this.audio) {
1407
+ this.audio.src = "";
1408
+ this.audio.load();
1409
+ }
1410
+ this.hasError = false;
1411
+ if (this.errorEl) {
1412
+ this.errorEl.style.display = "none";
1413
+ }
1414
+ if (this.canvas) {
1415
+ this.canvas.style.opacity = "1";
1416
+ }
1417
+ if (this.playBtn) {
1418
+ this.playBtn.disabled = false;
1419
+ }
1420
+ this.progress = 0;
1421
+ this.waveformData = [];
1422
+ this.options = mergeOptions(this.options, {
1423
+ url,
1424
+ title: title || this.options.title,
1425
+ subtitle: subtitle || this.options.subtitle,
1426
+ ...options
1427
+ });
1428
+ if (options.preload && this.audio) {
1429
+ this.audio.preload = options.preload;
1430
+ }
1431
+ if (this.subtitleEl) {
1432
+ if (subtitle) {
1433
+ this.subtitleEl.textContent = subtitle;
1434
+ this.subtitleEl.style.display = "";
1435
+ } else if (subtitle === "") {
1436
+ this.subtitleEl.style.display = "none";
1437
+ }
1438
+ }
1439
+ if (options.artwork && this.artworkEl) {
1440
+ this.artworkEl.src = options.artwork;
1441
+ }
1442
+ this.options.markers = options.markers || [];
1443
+ this.options.waveform = options.waveform || null;
1444
+ await this.load(url);
1445
+ if (options.autoplay !== false) {
1446
+ this.play()?.catch(() => {
1447
+ });
1448
+ }
1449
+ }
1450
+ // ============================================
1451
+ // Visualization
1452
+ // ============================================
1453
+ /**
1454
+ * Normalise externally-supplied waveform data into `this.waveformData` and
1455
+ * redraw.
1456
+ *
1457
+ * Accepts several shapes: a `.json` URL (fetched async; peaks and any
1458
+ * embedded `markers` are applied on resolve), a JSON-encoded array string,
1459
+ * a comma-separated number string, or a plain number array. Malformed
1460
+ * input degrades to an empty array rather than throwing.
1461
+ * @param {string|number[]} data - Peaks as an array, a JSON/CSV string, or
1462
+ * a URL to a `.json` peaks file.
1463
+ * @private
1464
+ */
1465
+ setWaveformData(data) {
1466
+ if (typeof data === "string" && data.trim().endsWith(".json")) {
1467
+ fetch(data.trim()).then((r) => r.json()).then((json) => {
1468
+ this.waveformData = Array.isArray(json) ? json : json.peaks || [];
1469
+ if (json.markers && !this.options.markers?.length) {
1470
+ this.options.markers = json.markers;
1471
+ this.renderMarkers();
1472
+ }
1473
+ this.drawWaveform();
1474
+ }).catch(() => {
1475
+ });
1476
+ return;
1477
+ }
1478
+ if (typeof data === "string") {
1479
+ try {
1480
+ const parsed = JSON.parse(data);
1481
+ this.waveformData = Array.isArray(parsed) ? parsed : [];
1482
+ } catch {
1483
+ this.waveformData = data.split(",").map(Number);
1484
+ }
1485
+ } else {
1486
+ this.waveformData = Array.isArray(data) ? data : [];
1487
+ }
1488
+ this.drawWaveform();
1489
+ }
1490
+ /**
1491
+ * Render the current waveform + progress to the canvas via the shared
1492
+ * {@link draw} routine, passing the resolved style and colours. No-op
1493
+ * before the context exists or while there is no peak data.
1494
+ * @private
1495
+ */
1496
+ drawWaveform() {
1497
+ if (!this.ctx || this.waveformData.length === 0) return;
1498
+ draw(this.ctx, this.canvas, this.waveformData, this.progress, {
1499
+ ...this.options,
1500
+ waveformStyle: this.options.waveformStyle || "bars",
1501
+ color: this.options.waveformColor,
1502
+ progressColor: this.options.progressColor
1503
+ });
1504
+ }
1505
+ /**
1506
+ * Re-fit the canvas backing store to its parent's width and the configured
1507
+ * height, scaled by the device pixel ratio for crisp rendering, then
1508
+ * redraw. Guards against running after destruction.
1509
+ * @private
1510
+ */
1511
+ resizeCanvas() {
1512
+ if (!this.canvas || this.isDestroying) {
1513
+ return;
1514
+ }
1515
+ const dpr = window.devicePixelRatio || 1;
1516
+ const rect = this.canvas.parentElement.getBoundingClientRect();
1517
+ this.canvas.width = rect.width * dpr;
1518
+ this.canvas.height = this.options.height * dpr;
1519
+ this.canvas.parentElement.style.height = this.options.height + "px";
1520
+ this.drawWaveform();
1521
+ }
1522
+ /**
1523
+ * Render the configured cue markers as positioned, clickable buttons over
1524
+ * the waveform.
1525
+ *
1526
+ * Clears any existing markers first, then bails out unless `showMarkers` is
1527
+ * on, markers exist, and a duration is known (via the mode-agnostic
1528
+ * {@link WaveformPlayer#getSeekDuration}). Each marker is placed by its
1529
+ * time-as-percentage, carries a tooltip and ARIA label, and seeks on click
1530
+ * (also starting playback when `playOnSeek` is set and currently paused).
1531
+ * Markers past the track duration are skipped with a warning.
1532
+ * @private
1533
+ */
1534
+ renderMarkers() {
1535
+ if (!this.markersContainer) return;
1536
+ this.markersContainer.innerHTML = "";
1537
+ if (!this.options.showMarkers || !this.options.markers?.length) return;
1538
+ const duration = this.getSeekDuration();
1539
+ if (!duration) {
1540
+ return;
1541
+ }
1542
+ this.options.markers.forEach((marker, index) => {
1543
+ if (marker.time > duration) {
1544
+ console.warn(`[WaveformPlayer] Marker "${marker.label}" at ${marker.time}s exceeds audio duration of ${duration}s`);
1545
+ return;
1546
+ }
1547
+ const position = marker.time / duration * 100;
1548
+ const markerEl = document.createElement("button");
1549
+ markerEl.className = "waveform-marker";
1550
+ markerEl.style.left = `${position}%`;
1551
+ markerEl.style.backgroundColor = marker.color || "rgba(255, 255, 255, 0.5)";
1552
+ markerEl.setAttribute("aria-label", marker.label);
1553
+ markerEl.setAttribute("data-time", marker.time);
1554
+ const tooltip = document.createElement("span");
1555
+ tooltip.className = "waveform-marker-tooltip";
1556
+ tooltip.textContent = marker.label;
1557
+ markerEl.appendChild(tooltip);
1558
+ markerEl.addEventListener("click", (e) => {
1559
+ e.stopPropagation();
1560
+ this.seekTo(marker.time);
1561
+ if (this.options.playOnSeek && !this.isPlaying) {
1562
+ this.play();
1563
+ }
1564
+ });
1565
+ this.markersContainer.appendChild(markerEl);
1566
+ });
1567
+ }
1568
+ /**
1569
+ * Highlight the marker at `index` (toggling an `active` class) and clear
1570
+ * the rest. Pass `null` to clear all. Lets an external controller (e.g. a
1571
+ * DJ bar) reflect the current section without reaching into the player's
1572
+ * private marker DOM.
1573
+ * @param {number|null} index - Marker index to activate, or `null` to clear.
1574
+ */
1575
+ setActiveMarker(index) {
1576
+ if (!this.markersContainer) return;
1577
+ const markers = this.markersContainer.querySelectorAll(".waveform-marker");
1578
+ markers.forEach((el, i) => el.classList.toggle("active", i === index));
1579
+ }
1580
+ // ============================================
1581
+ // Event Handlers
1582
+ // ============================================
1583
+ /**
1584
+ * Seek to the clicked horizontal position on the waveform canvas.
1585
+ *
1586
+ * Converts the click X into a 0..1 percentage. In external mode it
1587
+ * dispatches a cancelable `waveformplayer:request-seek` event (updating the
1588
+ * local visual optimistically unless the controller vetoes it); in self
1589
+ * mode it seeks the owned `<audio>` via
1590
+ * {@link WaveformPlayer#seekToPercent}.
1591
+ * @param {MouseEvent} event - The canvas click event.
1592
+ * @private
1593
+ * @fires WaveformPlayer#waveformplayer:request-seek
1594
+ */
1595
+ handleCanvasClick(event) {
1596
+ const rect = this.canvas.getBoundingClientRect();
1597
+ const x = event.clientX - rect.left;
1598
+ const targetPercent = clamp(x / rect.width);
1599
+ if (this.options.audioMode === "external") {
1600
+ this._requestSeek(targetPercent);
1601
+ return;
1602
+ }
1603
+ if (!this.audio || !this.audio.duration) return;
1604
+ this.seekToPercent(targetPercent);
1605
+ }
1606
+ /**
1607
+ * Toggle the loading state: show/hide the spinner overlay and set
1608
+ * `aria-busy` on the accessible seek slider so assistive tech knows the
1609
+ * player is fetching/decoding.
1610
+ * @param {boolean} loading - True while audio is loading.
1611
+ * @private
1612
+ */
1613
+ setLoading(loading) {
1614
+ this.isLoading = loading;
1615
+ if (this.loadingEl) {
1616
+ this.loadingEl.style.display = loading ? "block" : "none";
1617
+ }
1618
+ if (this.seekEl) {
1619
+ this.seekEl.setAttribute("aria-busy", loading ? "true" : "false");
1620
+ }
1621
+ }
1622
+ /**
1623
+ * `loadedmetadata` handler (self mode): write the total-time display, now
1624
+ * that duration is known re-render markers, and publish duration to the
1625
+ * accessible seek slider. No-op during destruction.
1626
+ * @private
1627
+ */
1628
+ onMetadataLoaded() {
1629
+ if (this.isDestroying) return;
1630
+ if (this.totalTimeEl) {
1631
+ this.totalTimeEl.textContent = formatTime(this.audio.duration);
1632
+ }
1633
+ this.renderMarkers();
1634
+ this.updateSeekAccessibility();
1635
+ }
1636
+ /**
1637
+ * Reflect play/pause state on the transport button: toggle the `playing`
1638
+ * class and swap the play/pause icon visibility. The single source of
1639
+ * truth shared by `onPlay`, `onPause`, and the external-mode
1640
+ * `setPlayingState` pump so they can't drift. No-op without a button.
1641
+ * @param {boolean} isPlaying - Whether playback is active.
1642
+ * @private
1643
+ */
1644
+ setPlayButtonState(isPlaying) {
1645
+ if (!this.playBtn) return;
1646
+ this.playBtn.classList.toggle("playing", isPlaying);
1647
+ const playIcon = this.playBtn.querySelector(".waveform-icon-play");
1648
+ const pauseIcon = this.playBtn.querySelector(".waveform-icon-pause");
1649
+ if (playIcon) playIcon.style.display = isPlaying ? "none" : "flex";
1650
+ if (pauseIcon) pauseIcon.style.display = isPlaying ? "flex" : "none";
1651
+ }
1652
+ /**
1653
+ * `play` handler (self mode): set the playing flag, swap the button to its
1654
+ * pause icon, start the smooth progress loop, dispatch
1655
+ * `waveformplayer:play`, and fire the `onPlay` callback. No-op during
1656
+ * destruction.
1657
+ * @private
1658
+ * @fires WaveformPlayer#waveformplayer:play
1659
+ */
1660
+ onPlay() {
1661
+ if (this.isDestroying) return;
1662
+ this.isPlaying = true;
1663
+ this.setPlayButtonState(true);
1664
+ this.startSmoothUpdate();
1665
+ this._emit("waveformplayer:play", { player: this, url: this.options.url });
1666
+ if (this.options.onPlay) {
1667
+ this.options.onPlay(this);
1668
+ }
1669
+ }
1670
+ /**
1671
+ * `pause` handler (self mode): clear the playing flag, swap the button back
1672
+ * to its play icon, stop the smooth progress loop, dispatch
1673
+ * `waveformplayer:pause`, and fire the `onPause` callback. No-op during
1674
+ * destruction.
1675
+ * @private
1676
+ * @fires WaveformPlayer#waveformplayer:pause
1677
+ */
1678
+ onPause() {
1679
+ if (this.isDestroying) return;
1680
+ this.isPlaying = false;
1681
+ this.setPlayButtonState(false);
1682
+ this.stopSmoothUpdate();
1683
+ this._emit("waveformplayer:pause", { player: this, url: this.options.url });
1684
+ if (this.options.onPause) {
1685
+ this.options.onPause(this);
1686
+ }
1687
+ }
1688
+ /**
1689
+ * `ended` handler (self mode): reset progress and `currentTime` to the
1690
+ * start, redraw, reset the time display, dispatch `waveformplayer:ended`
1691
+ * (carrying the final time), run {@link WaveformPlayer#onPause}, and fire
1692
+ * the `onEnd` callback. No-op during destruction.
1693
+ * @private
1694
+ * @fires WaveformPlayer#waveformplayer:ended
1695
+ */
1696
+ onEnded() {
1697
+ if (this.isDestroying) return;
1698
+ const duration = this.audio.duration;
1699
+ this.progress = 0;
1700
+ this.audio.currentTime = 0;
1701
+ this.drawWaveform();
1702
+ if (this.currentTimeEl) {
1703
+ this.currentTimeEl.textContent = "0:00";
1704
+ }
1705
+ this._emit("waveformplayer:ended", { player: this, url: this.options.url, currentTime: duration, duration });
1706
+ this.onPause();
1707
+ if (this.options.onEnd) {
1708
+ this.options.onEnd(this);
1709
+ }
1710
+ }
1711
+ /**
1712
+ * `error` handler: set the error flag, hide the spinner, reveal the error
1713
+ * overlay, dim the canvas, disable the play button, and fire the `onError`
1714
+ * callback. No-op during destruction.
1715
+ * @param {Event|Error} error - The audio error event, or an Error thrown
1716
+ * during loading.
1717
+ * @private
1718
+ */
1719
+ onError(error) {
1720
+ if (this.isDestroying) return;
1721
+ console.error("[WaveformPlayer] Audio error:", error);
1722
+ this.hasError = true;
1723
+ this.setLoading(false);
1724
+ if (this.errorEl) {
1725
+ this.errorEl.style.display = "flex";
1726
+ }
1727
+ if (this.canvas) {
1728
+ this.canvas.style.opacity = "0.2";
1729
+ }
1730
+ if (this.playBtn) {
1731
+ this.playBtn.disabled = true;
1732
+ }
1733
+ if (this.options.onError) {
1734
+ this.options.onError(error, this);
1735
+ }
1736
+ }
1737
+ // ============================================
1738
+ // Progress Updates
1739
+ // ============================================
1740
+ /**
1741
+ * Start the `requestAnimationFrame` loop that drives smooth progress
1742
+ * updates while playing (self mode only — external mode is redrawn by
1743
+ * controller {@link WaveformPlayer#setProgress} pushes). Cancels any
1744
+ * existing loop first so it's safe to call repeatedly.
1745
+ * @private
1746
+ */
1747
+ startSmoothUpdate() {
1748
+ this.stopSmoothUpdate();
1749
+ const update = () => {
1750
+ if (this.isPlaying && this.audio && this.audio.duration) {
1751
+ this.updateProgress();
1752
+ this.updateTimer = requestAnimationFrame(update);
1753
+ }
1754
+ };
1755
+ this.updateTimer = requestAnimationFrame(update);
1756
+ }
1757
+ /**
1758
+ * Cancel the smooth-update animation frame, if one is scheduled.
1759
+ * @private
1760
+ */
1761
+ stopSmoothUpdate() {
1762
+ if (this.updateTimer) {
1763
+ cancelAnimationFrame(this.updateTimer);
1764
+ this.updateTimer = null;
1765
+ }
1766
+ }
1767
+ /**
1768
+ * Recompute progress from the owned `<audio>` clock and reflect it
1769
+ * everywhere (self mode only — external mode uses
1770
+ * {@link WaveformPlayer#setProgress}).
1771
+ *
1772
+ * Redraws the canvas when progress moves meaningfully, updates the
1773
+ * current-time display, dispatches `waveformplayer:timeupdate`, fires the
1774
+ * `onTimeUpdate` callback, and refreshes the accessible slider values.
1775
+ * @private
1776
+ * @fires WaveformPlayer#waveformplayer:timeupdate
1777
+ */
1778
+ updateProgress() {
1779
+ if (!this.audio || !this.audio.duration) return;
1780
+ const newProgress = this.audio.currentTime / this.audio.duration;
1781
+ if (Math.abs(newProgress - this.progress) > 1e-3) {
1782
+ this.progress = newProgress;
1783
+ this.drawWaveform();
1784
+ }
1785
+ if (this.currentTimeEl) {
1786
+ this.currentTimeEl.textContent = formatTime(this.audio.currentTime);
1787
+ }
1788
+ this._emit("waveformplayer:timeupdate", {
1789
+ player: this,
1790
+ currentTime: this.audio.currentTime,
1791
+ duration: this.audio.duration,
1792
+ progress: this.progress,
1793
+ url: this.options.url
1794
+ });
1795
+ if (this.options.onTimeUpdate) {
1796
+ this.options.onTimeUpdate(this.audio.currentTime, this.audio.duration, this);
1797
+ }
1798
+ this.updateSeekAccessibility();
1799
+ }
1800
+ // ============================================
1801
+ // UI Updates
1802
+ // ============================================
1803
+ /**
1804
+ * Show the detected BPM in the badge, once a value has been detected.
1805
+ * @private
1806
+ */
1807
+ updateBPMDisplay() {
1808
+ if (this.bpmEl && this.bpmValueEl && this.detectedBPM) {
1809
+ this.bpmValueEl.textContent = Math.round(this.detectedBPM);
1810
+ this.bpmEl.style.display = "inline-flex";
1811
+ }
1812
+ }
1813
+ /**
1814
+ * Sync the speed control's label and the menu's active-option highlight to
1815
+ * the audio element's current `playbackRate`. No-op in external mode (no
1816
+ * owned `<audio>`), which also avoids reading `playbackRate` before the
1817
+ * element exists.
1818
+ * @private
1819
+ */
1820
+ updateSpeedUI() {
1821
+ if (!this.audio) return;
1822
+ const speedValue = this.container.querySelector(".speed-value");
1823
+ if (speedValue) {
1824
+ const rate = this.audio.playbackRate;
1825
+ speedValue.textContent = rate === 1 ? "1x" : `${rate}x`;
1826
+ }
1827
+ this.container.querySelectorAll(".speed-option").forEach((btn) => {
1828
+ btn.classList.toggle("active", parseFloat(btn.dataset.rate) === this.audio.playbackRate);
1829
+ });
1830
+ }
1831
+ // ============================================
1832
+ // Public API
1833
+ // ============================================
1834
+ /**
1835
+ * Play audio.
1836
+ *
1837
+ * In `audioMode: 'self'` (default): calls the underlying <audio>
1838
+ * element's play(). Returns the promise from HTMLMediaElement.play().
1839
+ *
1840
+ * In `audioMode: 'external'`: dispatches a cancelable
1841
+ * `waveformplayer:request-play` event with the track metadata and
1842
+ * does NOT touch any audio element. Returns `undefined`. An external
1843
+ * controller (e.g. WaveformBar) listens for this event and starts
1844
+ * playback on its own audio source, then pushes state back via
1845
+ * setPlayingState() / setProgress(). Calling preventDefault() on
1846
+ * the event lets the controller veto the play (state is unchanged).
1847
+ *
1848
+ * When `singlePlay` is enabled, any other currently-playing instance is
1849
+ * paused first.
1850
+ *
1851
+ * @return {Promise|undefined} The promise from `HTMLMediaElement.play()` in
1852
+ * self mode; `undefined` in external mode.
1853
+ * @fires WaveformPlayer#waveformplayer:request-play
1854
+ */
1855
+ play() {
1856
+ if (this.options.singlePlay && _WaveformPlayer.currentlyPlaying && _WaveformPlayer.currentlyPlaying !== this) {
1857
+ _WaveformPlayer.currentlyPlaying.pause();
1858
+ }
1859
+ if (this.options.audioMode === "external") {
1860
+ const evt = this._emit("waveformplayer:request-play", this._buildTrackDetail(), true);
1861
+ if (!evt.defaultPrevented) {
1862
+ _WaveformPlayer.currentlyPlaying = this;
1863
+ }
1864
+ return void 0;
1865
+ }
1866
+ _WaveformPlayer.currentlyPlaying = this;
1867
+ return this.audio.play();
1868
+ }
1869
+ /**
1870
+ * Pause audio.
1871
+ *
1872
+ * In `audioMode: 'external'`, dispatches `waveformplayer:request-pause`
1873
+ * (cancelable) and does NOT touch any audio element. See play().
1874
+ *
1875
+ * @fires WaveformPlayer#waveformplayer:request-pause
1876
+ */
1877
+ pause() {
1878
+ if (_WaveformPlayer.currentlyPlaying === this) {
1879
+ _WaveformPlayer.currentlyPlaying = null;
1880
+ }
1881
+ if (this.options.audioMode === "external") {
1882
+ this._emit("waveformplayer:request-pause", this._buildTrackDetail(), true);
1883
+ return;
1884
+ }
1885
+ this.audio.pause();
1886
+ }
1887
+ /**
1888
+ * Build the track detail object dispatched by request-play /
1889
+ * request-pause events in external audio mode. Mirrors the shape
1890
+ * WaveformBar.play() accepts so a controller can forward it
1891
+ * directly: `WaveformBar.play(event.detail)`.
1892
+ *
1893
+ * @private
1894
+ * @return {{url:string,title:?string,subtitle:?string,artist:?string,artwork:?string,player:WaveformPlayer}}
1895
+ */
1896
+ _buildTrackDetail() {
1897
+ return {
1898
+ url: this.options.url,
1899
+ title: this.options.title,
1900
+ subtitle: this.options.subtitle,
1901
+ // Core has no separate `artist` option; mirror subtitle so the
1902
+ // published event detail is self-consistent for controllers.
1903
+ artist: this.options.artist || this.options.subtitle,
1904
+ artwork: this.options.artwork,
1905
+ markers: this.options.markers,
1906
+ waveform: this.options.waveform,
1907
+ id: this.id,
1908
+ player: this
1909
+ };
1910
+ }
1911
+ /**
1912
+ * External-mode state pump: flip the play/pause visual state without
1913
+ * touching audio. Mirrors what onPlay()/onPause() do but skips the
1914
+ * audio-element interactions. Safe to call repeatedly — idempotent.
1915
+ *
1916
+ * Only dispatches `waveformplayer:play`/`waveformplayer:pause` (and runs
1917
+ * the matching callback) on an actual transition, starting/stopping the
1918
+ * smooth-update loop accordingly.
1919
+ *
1920
+ * @param {boolean} playing - True to enter the playing state, false to
1921
+ * enter the paused state.
1922
+ * @fires WaveformPlayer#waveformplayer:play
1923
+ * @fires WaveformPlayer#waveformplayer:pause
1924
+ */
1925
+ setPlayingState(playing) {
1926
+ const wasPlaying = this.isPlaying;
1927
+ this.isPlaying = !!playing;
1928
+ this.setPlayButtonState(this.isPlaying);
1929
+ if (this.isPlaying && !wasPlaying) {
1930
+ this.startSmoothUpdate?.();
1931
+ this._emit("waveformplayer:play", { player: this, url: this.options.url });
1932
+ if (this.options.onPlay) this.options.onPlay(this);
1933
+ } else if (!this.isPlaying && wasPlaying) {
1934
+ this.stopSmoothUpdate?.();
1935
+ this._emit("waveformplayer:pause", { player: this, url: this.options.url });
1936
+ if (this.options.onPause) this.options.onPause(this);
1937
+ }
1938
+ }
1939
+ /**
1940
+ * External-mode state pump: update the visualization's progress
1941
+ * from an external clock (e.g. WaveformBar's audio element's
1942
+ * timeupdate). Drives the canvas redraw + the time displays.
1943
+ *
1944
+ * Redraws the canvas, updates the current/total time displays, stores the
1945
+ * external duration for the accessible slider, dispatches
1946
+ * `waveformplayer:timeupdate`, runs `onTimeUpdate`, and synthesizes a
1947
+ * one-shot `waveformplayer:ended` (with `onEnd`) when progress reaches the
1948
+ * end. No-op for a non-positive duration.
1949
+ *
1950
+ * @param {number} currentTime - Current playback position in seconds.
1951
+ * @param {number} duration - Total track duration in seconds.
1952
+ * @fires WaveformPlayer#waveformplayer:timeupdate
1953
+ * @fires WaveformPlayer#waveformplayer:ended
1954
+ */
1955
+ setProgress(currentTime, duration) {
1956
+ if (!duration || duration <= 0) return;
1957
+ this.progress = clamp(currentTime / duration);
1958
+ if (this.currentTimeEl) this.currentTimeEl.textContent = formatTime(currentTime);
1959
+ this._extDuration = duration;
1960
+ if (this.totalTimeEl && (!this.totalTimeEl.dataset._extSet || this.totalTimeEl.dataset._extDur !== String(duration))) {
1961
+ this.totalTimeEl.textContent = formatTime(duration);
1962
+ this.totalTimeEl.dataset._extSet = "1";
1963
+ this.totalTimeEl.dataset._extDur = String(duration);
1964
+ }
1965
+ this.drawWaveform?.();
1966
+ this._emit("waveformplayer:timeupdate", { player: this, currentTime, duration, progress: this.progress, url: this.options.url });
1967
+ if (this.options.onTimeUpdate) this.options.onTimeUpdate(currentTime, duration, this);
1968
+ if (this.progress >= 1) {
1969
+ if (!this._extEnded) {
1970
+ this._extEnded = true;
1971
+ this._emit("waveformplayer:ended", { player: this, url: this.options.url, currentTime: duration, duration });
1972
+ if (this.options.onEnd) this.options.onEnd(this);
1973
+ }
1974
+ } else {
1975
+ this._extEnded = false;
1976
+ }
1977
+ this.updateSeekAccessibility();
1978
+ }
1979
+ /**
1980
+ * Toggle between play and pause based on the current `isPlaying` state.
1981
+ * Works in both audio modes (in external mode it routes through the
1982
+ * request-play/pause events).
1983
+ */
1984
+ togglePlay() {
1985
+ if (this.isPlaying) {
1986
+ this.pause();
1987
+ } else {
1988
+ this.play();
1989
+ }
1990
+ }
1991
+ /**
1992
+ * Seek the owned `<audio>` element to an absolute time, clamped to
1993
+ * `[0, duration]`, and refresh progress. Self mode only — a no-op when
1994
+ * there is no audio element or duration. External-mode keyboard/click
1995
+ * seeks go through {@link WaveformPlayer#seekToSeconds} instead.
1996
+ * @param {number} seconds - Target time in seconds.
1997
+ */
1998
+ seekTo(seconds) {
1999
+ if (this.audio && this.audio.duration) {
2000
+ this.audio.currentTime = clamp(seconds, 0, this.audio.duration);
2001
+ this.updateProgress();
2002
+ }
2003
+ }
2004
+ /**
2005
+ * Seek the owned `<audio>` element to a fraction of the track, clamped to
2006
+ * `[0, 1]`, and refresh progress. Self mode only — a no-op without an audio
2007
+ * element or duration.
2008
+ * @param {number} percent - Position as a fraction from 0 to 1.
2009
+ */
2010
+ seekToPercent(percent) {
2011
+ if (this.audio && this.audio.duration) {
2012
+ this.audio.currentTime = this.audio.duration * clamp(percent);
2013
+ this.updateProgress();
2014
+ }
2015
+ }
2016
+ /**
2017
+ * Set the owned `<audio>` element's volume, clamped to `[0, 1]`. Self mode
2018
+ * only — a no-op in external mode where the controller owns volume.
2019
+ * @param {number} volume - Volume from 0 (silent) to 1 (full).
2020
+ */
2021
+ setVolume(volume) {
2022
+ const v = Number(volume);
2023
+ if (this.audio && Number.isFinite(v)) {
2024
+ this.audio.volume = clamp(v);
2025
+ }
2026
+ }
2027
+ /**
2028
+ * Set the owned `<audio>` element's playback rate (clamped to 0.5–2),
2029
+ * persist it onto `this.options.playbackRate`, and refresh the speed UI.
2030
+ * Self mode only — a no-op in external mode.
2031
+ * @param {number} rate - Desired playback rate; clamped to the 0.5–2 range.
2032
+ */
2033
+ setPlaybackRate(rate) {
2034
+ if (!this.audio) return;
2035
+ const clampedRate = clamp(rate, 0.5, 2);
2036
+ this.audio.playbackRate = clampedRate;
2037
+ this.options.playbackRate = clampedRate;
2038
+ this.updateSpeedUI();
2039
+ }
2040
+ /**
2041
+ * Tear down the player and release all resources.
2042
+ *
2043
+ * Flags destruction (so in-flight handlers bail), dispatches
2044
+ * `waveformplayer:destroy`, stops playback and the animation loop, aborts
2045
+ * every listener registered on the instance signal, disconnects the resize
2046
+ * observer, removes the window-resize handler, drops the instance from the
2047
+ * static map and `currentlyPlaying`, resets/releases the audio element, and
2048
+ * empties the container.
2049
+ * @fires WaveformPlayer#waveformplayer:destroy
2050
+ */
2051
+ destroy() {
2052
+ this.isDestroying = true;
2053
+ this._emit("waveformplayer:destroy", { player: this, url: this.options.url });
2054
+ this.pause();
2055
+ this.stopSmoothUpdate();
2056
+ this._ac?.abort();
2057
+ if (this.resizeObserver) {
2058
+ this.resizeObserver.disconnect();
2059
+ this.resizeObserver = null;
2060
+ }
2061
+ if (this.resizeHandler) {
2062
+ window.removeEventListener("resize", this.resizeHandler);
2063
+ this.resizeHandler = null;
2064
+ }
2065
+ _WaveformPlayer.instances.delete(this.id);
2066
+ if (_WaveformPlayer.currentlyPlaying === this) {
2067
+ _WaveformPlayer.currentlyPlaying = null;
2068
+ }
2069
+ if (this.audio) {
2070
+ this.audio.pause();
2071
+ this.audio.src = "";
2072
+ this.audio.load();
2073
+ this.audio = null;
2074
+ }
2075
+ this.container.innerHTML = "";
2076
+ this.canvas = null;
2077
+ this.ctx = null;
2078
+ this.playBtn = null;
2079
+ this.waveformData = [];
2080
+ }
2081
+ // ============================================
2082
+ // Static Methods
2083
+ // ============================================
2084
+ /**
2085
+ * Get player instance by ID, element, or element ID
2086
+ * @param {string|HTMLElement} idOrElement - Player ID, element, or element ID
2087
+ * @returns {WaveformPlayer|undefined}
2088
+ */
2089
+ static getInstance(idOrElement) {
2090
+ if (typeof idOrElement === "string") {
2091
+ const instance = this.instances.get(idOrElement);
2092
+ if (instance) return instance;
2093
+ const element = document.getElementById(idOrElement);
2094
+ if (element) {
2095
+ return Array.from(this.instances.values()).find((p) => p.container === element);
2096
+ }
2097
+ }
2098
+ if (idOrElement instanceof HTMLElement) {
2099
+ return Array.from(this.instances.values()).find((p) => p.container === idOrElement);
2100
+ }
2101
+ return void 0;
2102
+ }
2103
+ /**
2104
+ * Get all player instances
2105
+ * @returns {WaveformPlayer[]}
2106
+ */
2107
+ static getAllInstances() {
2108
+ return Array.from(this.instances.values());
2109
+ }
2110
+ /**
2111
+ * Destroy all player instances
2112
+ */
2113
+ static destroyAll() {
2114
+ this.instances.forEach((player) => player.destroy());
2115
+ this.instances.clear();
2116
+ }
2117
+ /**
2118
+ * Generate waveform data from audio URL
2119
+ * @static
2120
+ * @param {string} url - Audio URL
2121
+ * @param {number} samples - Number of samples
2122
+ * @returns {Promise<number[]>} Waveform peak data
2123
+ */
2124
+ static async generateWaveformData(url, samples = 200) {
2125
+ try {
2126
+ const result = await generateWaveform(url, samples);
2127
+ return result.peaks;
2128
+ } catch (error) {
2129
+ console.error("[WaveformPlayer] Failed to generate waveform:", error);
2130
+ throw error;
2131
+ }
2132
+ }
2133
+ /**
2134
+ * Derive a peaks-JSON URL from an audio URL by swapping the
2135
+ * extension. Strict counterpart to `generateWaveformData()`:
2136
+ * `generateWaveformData` decodes the audio at runtime,
2137
+ * `getPeaksUrl` assumes you generated the peaks at build time
2138
+ * (e.g. with `@arraypress/waveform-gen`) and stored the JSON
2139
+ * alongside the audio file.
2140
+ *
2141
+ * Use the result as the `waveform` option — the player detects
2142
+ * the `.json` suffix, `fetch()`es the file, and skips the Web
2143
+ * Audio decode pass entirely. Big perf win on catalogues with
2144
+ * many tracks (saves ~1-5s decode per file on slow connections).
2145
+ *
2146
+ * Recognised extensions: mp3, wav, ogg, flac, m4a, aac.
2147
+ * Preserves query strings + URL fragments. Returns `undefined`
2148
+ * for unrecognised inputs so callers can pass through
2149
+ * unconditionally:
2150
+ *
2151
+ * new WaveformPlayer('#el', {
2152
+ * url: track.audioUrl,
2153
+ * waveform: WaveformPlayer.getPeaksUrl(track.audioUrl),
2154
+ * });
2155
+ *
2156
+ * @static
2157
+ * @param {string|undefined|null} audioUrl - Audio file URL.
2158
+ * @returns {string|undefined} Peaks JSON URL, or `undefined`
2159
+ * when the input is empty / has no recognised audio extension.
2160
+ *
2161
+ * @example
2162
+ * WaveformPlayer.getPeaksUrl('/audio/track.mp3')
2163
+ * // '/audio/track.json'
2164
+ *
2165
+ * WaveformPlayer.getPeaksUrl('/audio/track.wav?v=2')
2166
+ * // '/audio/track.json?v=2'
2167
+ *
2168
+ * WaveformPlayer.getPeaksUrl(undefined)
2169
+ * // undefined
2170
+ */
2171
+ static getPeaksUrl(audioUrl) {
2172
+ if (!audioUrl) return void 0;
2173
+ const swapped = audioUrl.replace(
2174
+ /\.(mp3|wav|ogg|flac|m4a|aac)(\?[^#]*)?(#.*)?$/i,
2175
+ ".json$2$3"
2176
+ );
2177
+ return swapped === audioUrl ? void 0 : swapped;
2178
+ }
2179
+ };
2180
+
2181
+ // src/js/index.js
2182
+ WaveformPlayer.utils = { formatTime, extractTitleFromUrl, escapeHtml, isSafeHref };
2183
+ var isBrowser = () => typeof window !== "undefined" && typeof document !== "undefined";
2184
+ function autoInit() {
2185
+ if (!isBrowser()) return;
2186
+ const elements = document.querySelectorAll("[data-waveform-player]");
2187
+ elements.forEach((element) => {
2188
+ if (element.dataset.waveformInitialized === "true") return;
2189
+ try {
2190
+ new WaveformPlayer(element);
2191
+ element.dataset.waveformInitialized = "true";
2192
+ } catch (error) {
2193
+ console.error("[WaveformPlayer] Failed to initialize:", error, element);
2194
+ }
2195
+ });
2196
+ }
2197
+ if (isBrowser()) {
2198
+ if (document.readyState === "loading") {
2199
+ document.addEventListener("DOMContentLoaded", autoInit);
2200
+ } else {
2201
+ autoInit();
2202
+ }
2203
+ }
2204
+ WaveformPlayer.init = autoInit;
2205
+ if (isBrowser()) {
2206
+ window.WaveformPlayer = WaveformPlayer;
2207
+ }
2208
+ var index_default = WaveformPlayer;