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