@arraypress/waveform-player 1.7.1 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2207 @@
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
+ await this.load(url);
1444
+ if (options.autoplay !== false) {
1445
+ this.play()?.catch(() => {
1446
+ });
1447
+ }
1448
+ }
1449
+ // ============================================
1450
+ // Visualization
1451
+ // ============================================
1452
+ /**
1453
+ * Normalise externally-supplied waveform data into `this.waveformData` and
1454
+ * redraw.
1455
+ *
1456
+ * Accepts several shapes: a `.json` URL (fetched async; peaks and any
1457
+ * embedded `markers` are applied on resolve), a JSON-encoded array string,
1458
+ * a comma-separated number string, or a plain number array. Malformed
1459
+ * input degrades to an empty array rather than throwing.
1460
+ * @param {string|number[]} data - Peaks as an array, a JSON/CSV string, or
1461
+ * a URL to a `.json` peaks file.
1462
+ * @private
1463
+ */
1464
+ setWaveformData(data) {
1465
+ if (typeof data === "string" && data.trim().endsWith(".json")) {
1466
+ fetch(data.trim()).then((r) => r.json()).then((json) => {
1467
+ this.waveformData = Array.isArray(json) ? json : json.peaks || [];
1468
+ if (json.markers && !this.options.markers?.length) {
1469
+ this.options.markers = json.markers;
1470
+ this.renderMarkers();
1471
+ }
1472
+ this.drawWaveform();
1473
+ }).catch(() => {
1474
+ });
1475
+ return;
1476
+ }
1477
+ if (typeof data === "string") {
1478
+ try {
1479
+ const parsed = JSON.parse(data);
1480
+ this.waveformData = Array.isArray(parsed) ? parsed : [];
1481
+ } catch {
1482
+ this.waveformData = data.split(",").map(Number);
1483
+ }
1484
+ } else {
1485
+ this.waveformData = Array.isArray(data) ? data : [];
1486
+ }
1487
+ this.drawWaveform();
1488
+ }
1489
+ /**
1490
+ * Render the current waveform + progress to the canvas via the shared
1491
+ * {@link draw} routine, passing the resolved style and colours. No-op
1492
+ * before the context exists or while there is no peak data.
1493
+ * @private
1494
+ */
1495
+ drawWaveform() {
1496
+ if (!this.ctx || this.waveformData.length === 0) return;
1497
+ draw(this.ctx, this.canvas, this.waveformData, this.progress, {
1498
+ ...this.options,
1499
+ waveformStyle: this.options.waveformStyle || "bars",
1500
+ color: this.options.waveformColor,
1501
+ progressColor: this.options.progressColor
1502
+ });
1503
+ }
1504
+ /**
1505
+ * Re-fit the canvas backing store to its parent's width and the configured
1506
+ * height, scaled by the device pixel ratio for crisp rendering, then
1507
+ * redraw. Guards against running after destruction.
1508
+ * @private
1509
+ */
1510
+ resizeCanvas() {
1511
+ if (!this.canvas || this.isDestroying) {
1512
+ return;
1513
+ }
1514
+ const dpr = window.devicePixelRatio || 1;
1515
+ const rect = this.canvas.parentElement.getBoundingClientRect();
1516
+ this.canvas.width = rect.width * dpr;
1517
+ this.canvas.height = this.options.height * dpr;
1518
+ this.canvas.parentElement.style.height = this.options.height + "px";
1519
+ this.drawWaveform();
1520
+ }
1521
+ /**
1522
+ * Render the configured cue markers as positioned, clickable buttons over
1523
+ * the waveform.
1524
+ *
1525
+ * Clears any existing markers first, then bails out unless `showMarkers` is
1526
+ * on, markers exist, and a duration is known (via the mode-agnostic
1527
+ * {@link WaveformPlayer#getSeekDuration}). Each marker is placed by its
1528
+ * time-as-percentage, carries a tooltip and ARIA label, and seeks on click
1529
+ * (also starting playback when `playOnSeek` is set and currently paused).
1530
+ * Markers past the track duration are skipped with a warning.
1531
+ * @private
1532
+ */
1533
+ renderMarkers() {
1534
+ if (!this.markersContainer) return;
1535
+ this.markersContainer.innerHTML = "";
1536
+ if (!this.options.showMarkers || !this.options.markers?.length) return;
1537
+ const duration = this.getSeekDuration();
1538
+ if (!duration) {
1539
+ return;
1540
+ }
1541
+ this.options.markers.forEach((marker, index) => {
1542
+ if (marker.time > duration) {
1543
+ console.warn(`[WaveformPlayer] Marker "${marker.label}" at ${marker.time}s exceeds audio duration of ${duration}s`);
1544
+ return;
1545
+ }
1546
+ const position = marker.time / duration * 100;
1547
+ const markerEl = document.createElement("button");
1548
+ markerEl.className = "waveform-marker";
1549
+ markerEl.style.left = `${position}%`;
1550
+ markerEl.style.backgroundColor = marker.color || "rgba(255, 255, 255, 0.5)";
1551
+ markerEl.setAttribute("aria-label", marker.label);
1552
+ markerEl.setAttribute("data-time", marker.time);
1553
+ const tooltip = document.createElement("span");
1554
+ tooltip.className = "waveform-marker-tooltip";
1555
+ tooltip.textContent = marker.label;
1556
+ markerEl.appendChild(tooltip);
1557
+ markerEl.addEventListener("click", (e) => {
1558
+ e.stopPropagation();
1559
+ this.seekTo(marker.time);
1560
+ if (this.options.playOnSeek && !this.isPlaying) {
1561
+ this.play();
1562
+ }
1563
+ });
1564
+ this.markersContainer.appendChild(markerEl);
1565
+ });
1566
+ }
1567
+ /**
1568
+ * Highlight the marker at `index` (toggling an `active` class) and clear
1569
+ * the rest. Pass `null` to clear all. Lets an external controller (e.g. a
1570
+ * DJ bar) reflect the current section without reaching into the player's
1571
+ * private marker DOM.
1572
+ * @param {number|null} index - Marker index to activate, or `null` to clear.
1573
+ */
1574
+ setActiveMarker(index) {
1575
+ if (!this.markersContainer) return;
1576
+ const markers = this.markersContainer.querySelectorAll(".waveform-marker");
1577
+ markers.forEach((el, i) => el.classList.toggle("active", i === index));
1578
+ }
1579
+ // ============================================
1580
+ // Event Handlers
1581
+ // ============================================
1582
+ /**
1583
+ * Seek to the clicked horizontal position on the waveform canvas.
1584
+ *
1585
+ * Converts the click X into a 0..1 percentage. In external mode it
1586
+ * dispatches a cancelable `waveformplayer:request-seek` event (updating the
1587
+ * local visual optimistically unless the controller vetoes it); in self
1588
+ * mode it seeks the owned `<audio>` via
1589
+ * {@link WaveformPlayer#seekToPercent}.
1590
+ * @param {MouseEvent} event - The canvas click event.
1591
+ * @private
1592
+ * @fires WaveformPlayer#waveformplayer:request-seek
1593
+ */
1594
+ handleCanvasClick(event) {
1595
+ const rect = this.canvas.getBoundingClientRect();
1596
+ const x = event.clientX - rect.left;
1597
+ const targetPercent = clamp(x / rect.width);
1598
+ if (this.options.audioMode === "external") {
1599
+ this._requestSeek(targetPercent);
1600
+ return;
1601
+ }
1602
+ if (!this.audio || !this.audio.duration) return;
1603
+ this.seekToPercent(targetPercent);
1604
+ }
1605
+ /**
1606
+ * Toggle the loading state: show/hide the spinner overlay and set
1607
+ * `aria-busy` on the accessible seek slider so assistive tech knows the
1608
+ * player is fetching/decoding.
1609
+ * @param {boolean} loading - True while audio is loading.
1610
+ * @private
1611
+ */
1612
+ setLoading(loading) {
1613
+ this.isLoading = loading;
1614
+ if (this.loadingEl) {
1615
+ this.loadingEl.style.display = loading ? "block" : "none";
1616
+ }
1617
+ if (this.seekEl) {
1618
+ this.seekEl.setAttribute("aria-busy", loading ? "true" : "false");
1619
+ }
1620
+ }
1621
+ /**
1622
+ * `loadedmetadata` handler (self mode): write the total-time display, now
1623
+ * that duration is known re-render markers, and publish duration to the
1624
+ * accessible seek slider. No-op during destruction.
1625
+ * @private
1626
+ */
1627
+ onMetadataLoaded() {
1628
+ if (this.isDestroying) return;
1629
+ if (this.totalTimeEl) {
1630
+ this.totalTimeEl.textContent = formatTime(this.audio.duration);
1631
+ }
1632
+ this.renderMarkers();
1633
+ this.updateSeekAccessibility();
1634
+ }
1635
+ /**
1636
+ * Reflect play/pause state on the transport button: toggle the `playing`
1637
+ * class and swap the play/pause icon visibility. The single source of
1638
+ * truth shared by `onPlay`, `onPause`, and the external-mode
1639
+ * `setPlayingState` pump so they can't drift. No-op without a button.
1640
+ * @param {boolean} isPlaying - Whether playback is active.
1641
+ * @private
1642
+ */
1643
+ setPlayButtonState(isPlaying) {
1644
+ if (!this.playBtn) return;
1645
+ this.playBtn.classList.toggle("playing", isPlaying);
1646
+ const playIcon = this.playBtn.querySelector(".waveform-icon-play");
1647
+ const pauseIcon = this.playBtn.querySelector(".waveform-icon-pause");
1648
+ if (playIcon) playIcon.style.display = isPlaying ? "none" : "flex";
1649
+ if (pauseIcon) pauseIcon.style.display = isPlaying ? "flex" : "none";
1650
+ }
1651
+ /**
1652
+ * `play` handler (self mode): set the playing flag, swap the button to its
1653
+ * pause icon, start the smooth progress loop, dispatch
1654
+ * `waveformplayer:play`, and fire the `onPlay` callback. No-op during
1655
+ * destruction.
1656
+ * @private
1657
+ * @fires WaveformPlayer#waveformplayer:play
1658
+ */
1659
+ onPlay() {
1660
+ if (this.isDestroying) return;
1661
+ this.isPlaying = true;
1662
+ this.setPlayButtonState(true);
1663
+ this.startSmoothUpdate();
1664
+ this._emit("waveformplayer:play", { player: this, url: this.options.url });
1665
+ if (this.options.onPlay) {
1666
+ this.options.onPlay(this);
1667
+ }
1668
+ }
1669
+ /**
1670
+ * `pause` handler (self mode): clear the playing flag, swap the button back
1671
+ * to its play icon, stop the smooth progress loop, dispatch
1672
+ * `waveformplayer:pause`, and fire the `onPause` callback. No-op during
1673
+ * destruction.
1674
+ * @private
1675
+ * @fires WaveformPlayer#waveformplayer:pause
1676
+ */
1677
+ onPause() {
1678
+ if (this.isDestroying) return;
1679
+ this.isPlaying = false;
1680
+ this.setPlayButtonState(false);
1681
+ this.stopSmoothUpdate();
1682
+ this._emit("waveformplayer:pause", { player: this, url: this.options.url });
1683
+ if (this.options.onPause) {
1684
+ this.options.onPause(this);
1685
+ }
1686
+ }
1687
+ /**
1688
+ * `ended` handler (self mode): reset progress and `currentTime` to the
1689
+ * start, redraw, reset the time display, dispatch `waveformplayer:ended`
1690
+ * (carrying the final time), run {@link WaveformPlayer#onPause}, and fire
1691
+ * the `onEnd` callback. No-op during destruction.
1692
+ * @private
1693
+ * @fires WaveformPlayer#waveformplayer:ended
1694
+ */
1695
+ onEnded() {
1696
+ if (this.isDestroying) return;
1697
+ const duration = this.audio.duration;
1698
+ this.progress = 0;
1699
+ this.audio.currentTime = 0;
1700
+ this.drawWaveform();
1701
+ if (this.currentTimeEl) {
1702
+ this.currentTimeEl.textContent = "0:00";
1703
+ }
1704
+ this._emit("waveformplayer:ended", { player: this, url: this.options.url, currentTime: duration, duration });
1705
+ this.onPause();
1706
+ if (this.options.onEnd) {
1707
+ this.options.onEnd(this);
1708
+ }
1709
+ }
1710
+ /**
1711
+ * `error` handler: set the error flag, hide the spinner, reveal the error
1712
+ * overlay, dim the canvas, disable the play button, and fire the `onError`
1713
+ * callback. No-op during destruction.
1714
+ * @param {Event|Error} error - The audio error event, or an Error thrown
1715
+ * during loading.
1716
+ * @private
1717
+ */
1718
+ onError(error) {
1719
+ if (this.isDestroying) return;
1720
+ console.error("[WaveformPlayer] Audio error:", error);
1721
+ this.hasError = true;
1722
+ this.setLoading(false);
1723
+ if (this.errorEl) {
1724
+ this.errorEl.style.display = "flex";
1725
+ }
1726
+ if (this.canvas) {
1727
+ this.canvas.style.opacity = "0.2";
1728
+ }
1729
+ if (this.playBtn) {
1730
+ this.playBtn.disabled = true;
1731
+ }
1732
+ if (this.options.onError) {
1733
+ this.options.onError(error, this);
1734
+ }
1735
+ }
1736
+ // ============================================
1737
+ // Progress Updates
1738
+ // ============================================
1739
+ /**
1740
+ * Start the `requestAnimationFrame` loop that drives smooth progress
1741
+ * updates while playing (self mode only — external mode is redrawn by
1742
+ * controller {@link WaveformPlayer#setProgress} pushes). Cancels any
1743
+ * existing loop first so it's safe to call repeatedly.
1744
+ * @private
1745
+ */
1746
+ startSmoothUpdate() {
1747
+ this.stopSmoothUpdate();
1748
+ const update = () => {
1749
+ if (this.isPlaying && this.audio && this.audio.duration) {
1750
+ this.updateProgress();
1751
+ this.updateTimer = requestAnimationFrame(update);
1752
+ }
1753
+ };
1754
+ this.updateTimer = requestAnimationFrame(update);
1755
+ }
1756
+ /**
1757
+ * Cancel the smooth-update animation frame, if one is scheduled.
1758
+ * @private
1759
+ */
1760
+ stopSmoothUpdate() {
1761
+ if (this.updateTimer) {
1762
+ cancelAnimationFrame(this.updateTimer);
1763
+ this.updateTimer = null;
1764
+ }
1765
+ }
1766
+ /**
1767
+ * Recompute progress from the owned `<audio>` clock and reflect it
1768
+ * everywhere (self mode only — external mode uses
1769
+ * {@link WaveformPlayer#setProgress}).
1770
+ *
1771
+ * Redraws the canvas when progress moves meaningfully, updates the
1772
+ * current-time display, dispatches `waveformplayer:timeupdate`, fires the
1773
+ * `onTimeUpdate` callback, and refreshes the accessible slider values.
1774
+ * @private
1775
+ * @fires WaveformPlayer#waveformplayer:timeupdate
1776
+ */
1777
+ updateProgress() {
1778
+ if (!this.audio || !this.audio.duration) return;
1779
+ const newProgress = this.audio.currentTime / this.audio.duration;
1780
+ if (Math.abs(newProgress - this.progress) > 1e-3) {
1781
+ this.progress = newProgress;
1782
+ this.drawWaveform();
1783
+ }
1784
+ if (this.currentTimeEl) {
1785
+ this.currentTimeEl.textContent = formatTime(this.audio.currentTime);
1786
+ }
1787
+ this._emit("waveformplayer:timeupdate", {
1788
+ player: this,
1789
+ currentTime: this.audio.currentTime,
1790
+ duration: this.audio.duration,
1791
+ progress: this.progress,
1792
+ url: this.options.url
1793
+ });
1794
+ if (this.options.onTimeUpdate) {
1795
+ this.options.onTimeUpdate(this.audio.currentTime, this.audio.duration, this);
1796
+ }
1797
+ this.updateSeekAccessibility();
1798
+ }
1799
+ // ============================================
1800
+ // UI Updates
1801
+ // ============================================
1802
+ /**
1803
+ * Show the detected BPM in the badge, once a value has been detected.
1804
+ * @private
1805
+ */
1806
+ updateBPMDisplay() {
1807
+ if (this.bpmEl && this.bpmValueEl && this.detectedBPM) {
1808
+ this.bpmValueEl.textContent = Math.round(this.detectedBPM);
1809
+ this.bpmEl.style.display = "inline-flex";
1810
+ }
1811
+ }
1812
+ /**
1813
+ * Sync the speed control's label and the menu's active-option highlight to
1814
+ * the audio element's current `playbackRate`. No-op in external mode (no
1815
+ * owned `<audio>`), which also avoids reading `playbackRate` before the
1816
+ * element exists.
1817
+ * @private
1818
+ */
1819
+ updateSpeedUI() {
1820
+ if (!this.audio) return;
1821
+ const speedValue = this.container.querySelector(".speed-value");
1822
+ if (speedValue) {
1823
+ const rate = this.audio.playbackRate;
1824
+ speedValue.textContent = rate === 1 ? "1x" : `${rate}x`;
1825
+ }
1826
+ this.container.querySelectorAll(".speed-option").forEach((btn) => {
1827
+ btn.classList.toggle("active", parseFloat(btn.dataset.rate) === this.audio.playbackRate);
1828
+ });
1829
+ }
1830
+ // ============================================
1831
+ // Public API
1832
+ // ============================================
1833
+ /**
1834
+ * Play audio.
1835
+ *
1836
+ * In `audioMode: 'self'` (default): calls the underlying <audio>
1837
+ * element's play(). Returns the promise from HTMLMediaElement.play().
1838
+ *
1839
+ * In `audioMode: 'external'`: dispatches a cancelable
1840
+ * `waveformplayer:request-play` event with the track metadata and
1841
+ * does NOT touch any audio element. Returns `undefined`. An external
1842
+ * controller (e.g. WaveformBar) listens for this event and starts
1843
+ * playback on its own audio source, then pushes state back via
1844
+ * setPlayingState() / setProgress(). Calling preventDefault() on
1845
+ * the event lets the controller veto the play (state is unchanged).
1846
+ *
1847
+ * When `singlePlay` is enabled, any other currently-playing instance is
1848
+ * paused first.
1849
+ *
1850
+ * @return {Promise|undefined} The promise from `HTMLMediaElement.play()` in
1851
+ * self mode; `undefined` in external mode.
1852
+ * @fires WaveformPlayer#waveformplayer:request-play
1853
+ */
1854
+ play() {
1855
+ if (this.options.singlePlay && _WaveformPlayer.currentlyPlaying && _WaveformPlayer.currentlyPlaying !== this) {
1856
+ _WaveformPlayer.currentlyPlaying.pause();
1857
+ }
1858
+ if (this.options.audioMode === "external") {
1859
+ const evt = this._emit("waveformplayer:request-play", this._buildTrackDetail(), true);
1860
+ if (!evt.defaultPrevented) {
1861
+ _WaveformPlayer.currentlyPlaying = this;
1862
+ }
1863
+ return void 0;
1864
+ }
1865
+ _WaveformPlayer.currentlyPlaying = this;
1866
+ return this.audio.play();
1867
+ }
1868
+ /**
1869
+ * Pause audio.
1870
+ *
1871
+ * In `audioMode: 'external'`, dispatches `waveformplayer:request-pause`
1872
+ * (cancelable) and does NOT touch any audio element. See play().
1873
+ *
1874
+ * @fires WaveformPlayer#waveformplayer:request-pause
1875
+ */
1876
+ pause() {
1877
+ if (_WaveformPlayer.currentlyPlaying === this) {
1878
+ _WaveformPlayer.currentlyPlaying = null;
1879
+ }
1880
+ if (this.options.audioMode === "external") {
1881
+ this._emit("waveformplayer:request-pause", this._buildTrackDetail(), true);
1882
+ return;
1883
+ }
1884
+ this.audio.pause();
1885
+ }
1886
+ /**
1887
+ * Build the track detail object dispatched by request-play /
1888
+ * request-pause events in external audio mode. Mirrors the shape
1889
+ * WaveformBar.play() accepts so a controller can forward it
1890
+ * directly: `WaveformBar.play(event.detail)`.
1891
+ *
1892
+ * @private
1893
+ * @return {{url:string,title:?string,subtitle:?string,artist:?string,artwork:?string,player:WaveformPlayer}}
1894
+ */
1895
+ _buildTrackDetail() {
1896
+ return {
1897
+ url: this.options.url,
1898
+ title: this.options.title,
1899
+ subtitle: this.options.subtitle,
1900
+ // Core has no separate `artist` option; mirror subtitle so the
1901
+ // published event detail is self-consistent for controllers.
1902
+ artist: this.options.artist || this.options.subtitle,
1903
+ artwork: this.options.artwork,
1904
+ markers: this.options.markers,
1905
+ waveform: this.options.waveform,
1906
+ id: this.id,
1907
+ player: this
1908
+ };
1909
+ }
1910
+ /**
1911
+ * External-mode state pump: flip the play/pause visual state without
1912
+ * touching audio. Mirrors what onPlay()/onPause() do but skips the
1913
+ * audio-element interactions. Safe to call repeatedly — idempotent.
1914
+ *
1915
+ * Only dispatches `waveformplayer:play`/`waveformplayer:pause` (and runs
1916
+ * the matching callback) on an actual transition, starting/stopping the
1917
+ * smooth-update loop accordingly.
1918
+ *
1919
+ * @param {boolean} playing - True to enter the playing state, false to
1920
+ * enter the paused state.
1921
+ * @fires WaveformPlayer#waveformplayer:play
1922
+ * @fires WaveformPlayer#waveformplayer:pause
1923
+ */
1924
+ setPlayingState(playing) {
1925
+ const wasPlaying = this.isPlaying;
1926
+ this.isPlaying = !!playing;
1927
+ this.setPlayButtonState(this.isPlaying);
1928
+ if (this.isPlaying && !wasPlaying) {
1929
+ this.startSmoothUpdate?.();
1930
+ this._emit("waveformplayer:play", { player: this, url: this.options.url });
1931
+ if (this.options.onPlay) this.options.onPlay(this);
1932
+ } else if (!this.isPlaying && wasPlaying) {
1933
+ this.stopSmoothUpdate?.();
1934
+ this._emit("waveformplayer:pause", { player: this, url: this.options.url });
1935
+ if (this.options.onPause) this.options.onPause(this);
1936
+ }
1937
+ }
1938
+ /**
1939
+ * External-mode state pump: update the visualization's progress
1940
+ * from an external clock (e.g. WaveformBar's audio element's
1941
+ * timeupdate). Drives the canvas redraw + the time displays.
1942
+ *
1943
+ * Redraws the canvas, updates the current/total time displays, stores the
1944
+ * external duration for the accessible slider, dispatches
1945
+ * `waveformplayer:timeupdate`, runs `onTimeUpdate`, and synthesizes a
1946
+ * one-shot `waveformplayer:ended` (with `onEnd`) when progress reaches the
1947
+ * end. No-op for a non-positive duration.
1948
+ *
1949
+ * @param {number} currentTime - Current playback position in seconds.
1950
+ * @param {number} duration - Total track duration in seconds.
1951
+ * @fires WaveformPlayer#waveformplayer:timeupdate
1952
+ * @fires WaveformPlayer#waveformplayer:ended
1953
+ */
1954
+ setProgress(currentTime, duration) {
1955
+ if (!duration || duration <= 0) return;
1956
+ this.progress = clamp(currentTime / duration);
1957
+ if (this.currentTimeEl) this.currentTimeEl.textContent = formatTime(currentTime);
1958
+ this._extDuration = duration;
1959
+ if (this.totalTimeEl && (!this.totalTimeEl.dataset._extSet || this.totalTimeEl.dataset._extDur !== String(duration))) {
1960
+ this.totalTimeEl.textContent = formatTime(duration);
1961
+ this.totalTimeEl.dataset._extSet = "1";
1962
+ this.totalTimeEl.dataset._extDur = String(duration);
1963
+ }
1964
+ this.drawWaveform?.();
1965
+ this._emit("waveformplayer:timeupdate", { player: this, currentTime, duration, progress: this.progress, url: this.options.url });
1966
+ if (this.options.onTimeUpdate) this.options.onTimeUpdate(currentTime, duration, this);
1967
+ if (this.progress >= 1) {
1968
+ if (!this._extEnded) {
1969
+ this._extEnded = true;
1970
+ this._emit("waveformplayer:ended", { player: this, url: this.options.url, currentTime: duration, duration });
1971
+ if (this.options.onEnd) this.options.onEnd(this);
1972
+ }
1973
+ } else {
1974
+ this._extEnded = false;
1975
+ }
1976
+ this.updateSeekAccessibility();
1977
+ }
1978
+ /**
1979
+ * Toggle between play and pause based on the current `isPlaying` state.
1980
+ * Works in both audio modes (in external mode it routes through the
1981
+ * request-play/pause events).
1982
+ */
1983
+ togglePlay() {
1984
+ if (this.isPlaying) {
1985
+ this.pause();
1986
+ } else {
1987
+ this.play();
1988
+ }
1989
+ }
1990
+ /**
1991
+ * Seek the owned `<audio>` element to an absolute time, clamped to
1992
+ * `[0, duration]`, and refresh progress. Self mode only — a no-op when
1993
+ * there is no audio element or duration. External-mode keyboard/click
1994
+ * seeks go through {@link WaveformPlayer#seekToSeconds} instead.
1995
+ * @param {number} seconds - Target time in seconds.
1996
+ */
1997
+ seekTo(seconds) {
1998
+ if (this.audio && this.audio.duration) {
1999
+ this.audio.currentTime = clamp(seconds, 0, this.audio.duration);
2000
+ this.updateProgress();
2001
+ }
2002
+ }
2003
+ /**
2004
+ * Seek the owned `<audio>` element to a fraction of the track, clamped to
2005
+ * `[0, 1]`, and refresh progress. Self mode only — a no-op without an audio
2006
+ * element or duration.
2007
+ * @param {number} percent - Position as a fraction from 0 to 1.
2008
+ */
2009
+ seekToPercent(percent) {
2010
+ if (this.audio && this.audio.duration) {
2011
+ this.audio.currentTime = this.audio.duration * clamp(percent);
2012
+ this.updateProgress();
2013
+ }
2014
+ }
2015
+ /**
2016
+ * Set the owned `<audio>` element's volume, clamped to `[0, 1]`. Self mode
2017
+ * only — a no-op in external mode where the controller owns volume.
2018
+ * @param {number} volume - Volume from 0 (silent) to 1 (full).
2019
+ */
2020
+ setVolume(volume) {
2021
+ const v = Number(volume);
2022
+ if (this.audio && Number.isFinite(v)) {
2023
+ this.audio.volume = clamp(v);
2024
+ }
2025
+ }
2026
+ /**
2027
+ * Set the owned `<audio>` element's playback rate (clamped to 0.5–2),
2028
+ * persist it onto `this.options.playbackRate`, and refresh the speed UI.
2029
+ * Self mode only — a no-op in external mode.
2030
+ * @param {number} rate - Desired playback rate; clamped to the 0.5–2 range.
2031
+ */
2032
+ setPlaybackRate(rate) {
2033
+ if (!this.audio) return;
2034
+ const clampedRate = clamp(rate, 0.5, 2);
2035
+ this.audio.playbackRate = clampedRate;
2036
+ this.options.playbackRate = clampedRate;
2037
+ this.updateSpeedUI();
2038
+ }
2039
+ /**
2040
+ * Tear down the player and release all resources.
2041
+ *
2042
+ * Flags destruction (so in-flight handlers bail), dispatches
2043
+ * `waveformplayer:destroy`, stops playback and the animation loop, aborts
2044
+ * every listener registered on the instance signal, disconnects the resize
2045
+ * observer, removes the window-resize handler, drops the instance from the
2046
+ * static map and `currentlyPlaying`, resets/releases the audio element, and
2047
+ * empties the container.
2048
+ * @fires WaveformPlayer#waveformplayer:destroy
2049
+ */
2050
+ destroy() {
2051
+ this.isDestroying = true;
2052
+ this._emit("waveformplayer:destroy", { player: this, url: this.options.url });
2053
+ this.pause();
2054
+ this.stopSmoothUpdate();
2055
+ this._ac?.abort();
2056
+ if (this.resizeObserver) {
2057
+ this.resizeObserver.disconnect();
2058
+ this.resizeObserver = null;
2059
+ }
2060
+ if (this.resizeHandler) {
2061
+ window.removeEventListener("resize", this.resizeHandler);
2062
+ this.resizeHandler = null;
2063
+ }
2064
+ _WaveformPlayer.instances.delete(this.id);
2065
+ if (_WaveformPlayer.currentlyPlaying === this) {
2066
+ _WaveformPlayer.currentlyPlaying = null;
2067
+ }
2068
+ if (this.audio) {
2069
+ this.audio.pause();
2070
+ this.audio.src = "";
2071
+ this.audio.load();
2072
+ this.audio = null;
2073
+ }
2074
+ this.container.innerHTML = "";
2075
+ this.canvas = null;
2076
+ this.ctx = null;
2077
+ this.playBtn = null;
2078
+ this.waveformData = [];
2079
+ }
2080
+ // ============================================
2081
+ // Static Methods
2082
+ // ============================================
2083
+ /**
2084
+ * Get player instance by ID, element, or element ID
2085
+ * @param {string|HTMLElement} idOrElement - Player ID, element, or element ID
2086
+ * @returns {WaveformPlayer|undefined}
2087
+ */
2088
+ static getInstance(idOrElement) {
2089
+ if (typeof idOrElement === "string") {
2090
+ const instance = this.instances.get(idOrElement);
2091
+ if (instance) return instance;
2092
+ const element = document.getElementById(idOrElement);
2093
+ if (element) {
2094
+ return Array.from(this.instances.values()).find((p) => p.container === element);
2095
+ }
2096
+ }
2097
+ if (idOrElement instanceof HTMLElement) {
2098
+ return Array.from(this.instances.values()).find((p) => p.container === idOrElement);
2099
+ }
2100
+ return void 0;
2101
+ }
2102
+ /**
2103
+ * Get all player instances
2104
+ * @returns {WaveformPlayer[]}
2105
+ */
2106
+ static getAllInstances() {
2107
+ return Array.from(this.instances.values());
2108
+ }
2109
+ /**
2110
+ * Destroy all player instances
2111
+ */
2112
+ static destroyAll() {
2113
+ this.instances.forEach((player) => player.destroy());
2114
+ this.instances.clear();
2115
+ }
2116
+ /**
2117
+ * Generate waveform data from audio URL
2118
+ * @static
2119
+ * @param {string} url - Audio URL
2120
+ * @param {number} samples - Number of samples
2121
+ * @returns {Promise<number[]>} Waveform peak data
2122
+ */
2123
+ static async generateWaveformData(url, samples = 200) {
2124
+ try {
2125
+ const result = await generateWaveform(url, samples);
2126
+ return result.peaks;
2127
+ } catch (error) {
2128
+ console.error("[WaveformPlayer] Failed to generate waveform:", error);
2129
+ throw error;
2130
+ }
2131
+ }
2132
+ /**
2133
+ * Derive a peaks-JSON URL from an audio URL by swapping the
2134
+ * extension. Strict counterpart to `generateWaveformData()`:
2135
+ * `generateWaveformData` decodes the audio at runtime,
2136
+ * `getPeaksUrl` assumes you generated the peaks at build time
2137
+ * (e.g. with `@arraypress/waveform-gen`) and stored the JSON
2138
+ * alongside the audio file.
2139
+ *
2140
+ * Use the result as the `waveform` option — the player detects
2141
+ * the `.json` suffix, `fetch()`es the file, and skips the Web
2142
+ * Audio decode pass entirely. Big perf win on catalogues with
2143
+ * many tracks (saves ~1-5s decode per file on slow connections).
2144
+ *
2145
+ * Recognised extensions: mp3, wav, ogg, flac, m4a, aac.
2146
+ * Preserves query strings + URL fragments. Returns `undefined`
2147
+ * for unrecognised inputs so callers can pass through
2148
+ * unconditionally:
2149
+ *
2150
+ * new WaveformPlayer('#el', {
2151
+ * url: track.audioUrl,
2152
+ * waveform: WaveformPlayer.getPeaksUrl(track.audioUrl),
2153
+ * });
2154
+ *
2155
+ * @static
2156
+ * @param {string|undefined|null} audioUrl - Audio file URL.
2157
+ * @returns {string|undefined} Peaks JSON URL, or `undefined`
2158
+ * when the input is empty / has no recognised audio extension.
2159
+ *
2160
+ * @example
2161
+ * WaveformPlayer.getPeaksUrl('/audio/track.mp3')
2162
+ * // '/audio/track.json'
2163
+ *
2164
+ * WaveformPlayer.getPeaksUrl('/audio/track.wav?v=2')
2165
+ * // '/audio/track.json?v=2'
2166
+ *
2167
+ * WaveformPlayer.getPeaksUrl(undefined)
2168
+ * // undefined
2169
+ */
2170
+ static getPeaksUrl(audioUrl) {
2171
+ if (!audioUrl) return void 0;
2172
+ const swapped = audioUrl.replace(
2173
+ /\.(mp3|wav|ogg|flac|m4a|aac)(\?[^#]*)?(#.*)?$/i,
2174
+ ".json$2$3"
2175
+ );
2176
+ return swapped === audioUrl ? void 0 : swapped;
2177
+ }
2178
+ };
2179
+
2180
+ // src/js/index.js
2181
+ WaveformPlayer.utils = { formatTime, extractTitleFromUrl, escapeHtml, isSafeHref };
2182
+ var isBrowser = () => typeof window !== "undefined" && typeof document !== "undefined";
2183
+ function autoInit() {
2184
+ if (!isBrowser()) return;
2185
+ const elements = document.querySelectorAll("[data-waveform-player]");
2186
+ elements.forEach((element) => {
2187
+ if (element.dataset.waveformInitialized === "true") return;
2188
+ try {
2189
+ new WaveformPlayer(element);
2190
+ element.dataset.waveformInitialized = "true";
2191
+ } catch (error) {
2192
+ console.error("[WaveformPlayer] Failed to initialize:", error, element);
2193
+ }
2194
+ });
2195
+ }
2196
+ if (isBrowser()) {
2197
+ if (document.readyState === "loading") {
2198
+ document.addEventListener("DOMContentLoaded", autoInit);
2199
+ } else {
2200
+ autoInit();
2201
+ }
2202
+ }
2203
+ WaveformPlayer.init = autoInit;
2204
+ if (isBrowser()) {
2205
+ window.WaveformPlayer = WaveformPlayer;
2206
+ }
2207
+ var index_default = WaveformPlayer;