@arraypress/waveform-player 1.7.2 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +176 -377
- 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 +552 -275
- 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 +21 -3
- package/src/js/audio.js +61 -25
- package/src/js/bpm.js +26 -5
- package/src/js/core.js +409 -185
- package/src/js/drawing.js +208 -44
- package/src/js/index.js +56 -11
- package/src/js/themes.js +88 -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)
|
|
@@ -628,6 +712,8 @@
|
|
|
628
712
|
subtitle: null,
|
|
629
713
|
artwork: null,
|
|
630
714
|
album: "",
|
|
715
|
+
// Message shown in the error state when audio fails to load.
|
|
716
|
+
errorText: "Unable to load audio",
|
|
631
717
|
// Icons (SVG)
|
|
632
718
|
playIcon: '<svg viewBox="0 0 24 24" width="16" height="16"><path d="M8 5v14l11-7z"/></svg>',
|
|
633
719
|
pauseIcon: '<svg viewBox="0 0 24 24" width="16" height="16"><path d="M6 4h4v16H6zM14 4h4v16h-4z"/></svg>',
|
|
@@ -657,17 +743,32 @@
|
|
|
657
743
|
/** @type {WaveformPlayer|null} */
|
|
658
744
|
static currentlyPlaying = null;
|
|
659
745
|
/**
|
|
660
|
-
* Create a new WaveformPlayer instance
|
|
661
|
-
*
|
|
662
|
-
*
|
|
746
|
+
* Create a new WaveformPlayer instance.
|
|
747
|
+
*
|
|
748
|
+
* Resolves the container, merges options (defaults < `data-*` attributes <
|
|
749
|
+
* constructor options), applies the colour preset and style-specific
|
|
750
|
+
* defaults, registers the instance in the static map, and kicks off
|
|
751
|
+
* {@link WaveformPlayer#init}. A `waveformplayer:ready` event is dispatched
|
|
752
|
+
* ~100ms later, once initialization has settled.
|
|
753
|
+
*
|
|
754
|
+
* @param {string|HTMLElement} container - Container element, or a CSS
|
|
755
|
+
* selector resolved with `document.querySelector`.
|
|
756
|
+
* @param {Object} [options={}] - Player options. Accepts the shorthand
|
|
757
|
+
* aliases `style` (→ `waveformStyle`) and `src` (→ `url`); the canonical
|
|
758
|
+
* names win if both are supplied.
|
|
759
|
+
* @throws {Error} If the container element cannot be found.
|
|
760
|
+
* @fires WaveformPlayer#waveformplayer:ready
|
|
663
761
|
*/
|
|
664
762
|
constructor(container, options = {}) {
|
|
665
763
|
this.container = typeof container === "string" ? document.querySelector(container) : container;
|
|
666
764
|
if (!this.container) {
|
|
667
|
-
throw new Error("WaveformPlayer
|
|
765
|
+
throw new Error("[WaveformPlayer] Container element not found");
|
|
668
766
|
}
|
|
669
767
|
const dataOptions = parseDataAttributes(this.container);
|
|
670
|
-
|
|
768
|
+
const userOptions = { ...options };
|
|
769
|
+
if (userOptions.style && !userOptions.waveformStyle) userOptions.waveformStyle = userOptions.style;
|
|
770
|
+
if (userOptions.src && !userOptions.url) userOptions.url = userOptions.src;
|
|
771
|
+
this.options = mergeOptions(DEFAULT_OPTIONS, dataOptions, userOptions);
|
|
671
772
|
const preset = getColorPreset(this.options.colorPreset);
|
|
672
773
|
for (const [key, value] of Object.entries(preset)) {
|
|
673
774
|
if (this.options[key] === null || this.options[key] === void 0) {
|
|
@@ -693,21 +794,54 @@
|
|
|
693
794
|
this.hasError = false;
|
|
694
795
|
this.updateTimer = null;
|
|
695
796
|
this.resizeObserver = null;
|
|
797
|
+
this._ac = new AbortController();
|
|
696
798
|
this.id = this.container.id || generateId(this.options.url);
|
|
697
799
|
_WaveformPlayer.instances.set(this.id, this);
|
|
698
800
|
this.init();
|
|
699
801
|
setTimeout(() => {
|
|
700
|
-
this.
|
|
701
|
-
bubbles: true,
|
|
702
|
-
detail: { player: this, url: this.options.url }
|
|
703
|
-
}));
|
|
802
|
+
this._emit("waveformplayer:ready", { player: this, url: this.options.url });
|
|
704
803
|
}, 100);
|
|
705
804
|
}
|
|
805
|
+
/**
|
|
806
|
+
* Build and dispatch a bubbling `waveformplayer:*` CustomEvent on the
|
|
807
|
+
* container, returning the event so cancelable (request-*) events can have
|
|
808
|
+
* their `defaultPrevented` checked. Single source of truth for the event
|
|
809
|
+
* shape — every player event bubbles and carries the supplied detail.
|
|
810
|
+
* @param {string} type - Full event type, e.g. `'waveformplayer:play'`.
|
|
811
|
+
* @param {Object} detail - Event detail payload.
|
|
812
|
+
* @param {boolean} [cancelable=false] - Whether the event is cancelable.
|
|
813
|
+
* @returns {CustomEvent} The dispatched event.
|
|
814
|
+
* @private
|
|
815
|
+
*/
|
|
816
|
+
_emit(type, detail, cancelable = false) {
|
|
817
|
+
const event = new CustomEvent(type, { bubbles: true, cancelable, detail });
|
|
818
|
+
this.container.dispatchEvent(event);
|
|
819
|
+
return event;
|
|
820
|
+
}
|
|
821
|
+
/**
|
|
822
|
+
* External-mode seek request: dispatch a cancelable
|
|
823
|
+
* `waveformplayer:request-seek` and, unless the controller calls
|
|
824
|
+
* `preventDefault()`, optimistically advance the local progress overlay so
|
|
825
|
+
* the canvas repaints at once. Shared by the keyboard slider and canvas click.
|
|
826
|
+
* @param {number} percent - Target position as a 0..1 fraction.
|
|
827
|
+
* @private
|
|
828
|
+
* @fires WaveformPlayer#waveformplayer:request-seek
|
|
829
|
+
*/
|
|
830
|
+
_requestSeek(percent) {
|
|
831
|
+
const evt = this._emit("waveformplayer:request-seek", { ...this._buildTrackDetail(), percent }, true);
|
|
832
|
+
if (!evt.defaultPrevented) {
|
|
833
|
+
this.progress = percent;
|
|
834
|
+
this.drawWaveform?.();
|
|
835
|
+
}
|
|
836
|
+
}
|
|
706
837
|
// ============================================
|
|
707
838
|
// Initialization
|
|
708
839
|
// ============================================
|
|
709
840
|
/**
|
|
710
|
-
* Initialize the player
|
|
841
|
+
* Initialize the player: build the DOM, create the audio element (self
|
|
842
|
+
* mode only), wire up the feature controls (speed, keyboard, accessible
|
|
843
|
+
* seek), bind events, attach the resize observer, then size the canvas and
|
|
844
|
+
* — if a `url` option was given — load it and optionally autoplay.
|
|
711
845
|
* @private
|
|
712
846
|
*/
|
|
713
847
|
init() {
|
|
@@ -723,16 +857,24 @@
|
|
|
723
857
|
if (this.options.url) {
|
|
724
858
|
this.load(this.options.url).then(() => {
|
|
725
859
|
if (this.options.autoplay) {
|
|
726
|
-
this.play()
|
|
860
|
+
this.play()?.catch(() => {
|
|
861
|
+
});
|
|
727
862
|
}
|
|
728
863
|
}).catch((error) => {
|
|
729
|
-
console.error("Failed to load audio:", error);
|
|
864
|
+
console.error("[WaveformPlayer] Failed to load audio:", error);
|
|
730
865
|
});
|
|
731
866
|
}
|
|
732
867
|
});
|
|
733
868
|
}
|
|
734
869
|
/**
|
|
735
|
-
*
|
|
870
|
+
* Build the player's DOM tree inside the container and cache element
|
|
871
|
+
* references.
|
|
872
|
+
*
|
|
873
|
+
* Clears the container, resolves button alignment (`auto` → `bottom` for
|
|
874
|
+
* the `bars` style, `center` otherwise), and conditionally renders the play
|
|
875
|
+
* button, info row (artwork/title/subtitle), BPM badge, playback-speed
|
|
876
|
+
* menu, and time display based on the relevant `show*` options. Caches the
|
|
877
|
+
* canvas, controls, and text elements onto `this`, then sizes the canvas.
|
|
736
878
|
* @private
|
|
737
879
|
*/
|
|
738
880
|
createDOM() {
|
|
@@ -807,8 +949,8 @@
|
|
|
807
949
|
<canvas></canvas>
|
|
808
950
|
<div class="waveform-markers"></div>
|
|
809
951
|
<div class="waveform-loading" style="display:none;"></div>
|
|
810
|
-
<div class="waveform-error" style="display:none;">
|
|
811
|
-
<span class="waveform-error-text"
|
|
952
|
+
<div class="waveform-error" style="display:none;" role="alert">
|
|
953
|
+
<span class="waveform-error-text">${escapeHtml(this.options.errorText)}</span>
|
|
812
954
|
</div>
|
|
813
955
|
</div>
|
|
814
956
|
</div>
|
|
@@ -857,7 +999,9 @@
|
|
|
857
999
|
// Feature Initialization
|
|
858
1000
|
// ============================================
|
|
859
1001
|
/**
|
|
860
|
-
*
|
|
1002
|
+
* Apply the configured initial playback rate to the audio element (self
|
|
1003
|
+
* mode only) and, when `showPlaybackSpeed` is enabled, wire up the speed
|
|
1004
|
+
* menu UI via {@link WaveformPlayer#initSpeedControls}.
|
|
861
1005
|
* @private
|
|
862
1006
|
*/
|
|
863
1007
|
initPlaybackSpeed() {
|
|
@@ -869,7 +1013,11 @@
|
|
|
869
1013
|
}
|
|
870
1014
|
}
|
|
871
1015
|
/**
|
|
872
|
-
*
|
|
1016
|
+
* Wire up the playback-speed menu: toggle it open on the speed button,
|
|
1017
|
+
* close it on any outside click, and apply the chosen rate when a
|
|
1018
|
+
* `.speed-option` is clicked. All listeners are registered against the
|
|
1019
|
+
* instance `AbortController` signal so {@link WaveformPlayer#destroy} tears
|
|
1020
|
+
* them down. No-op if the speed elements are absent.
|
|
873
1021
|
* @private
|
|
874
1022
|
*/
|
|
875
1023
|
initSpeedControls() {
|
|
@@ -879,10 +1027,10 @@
|
|
|
879
1027
|
speedBtn.addEventListener("click", (e) => {
|
|
880
1028
|
e.stopPropagation();
|
|
881
1029
|
speedMenu.style.display = speedMenu.style.display === "none" ? "block" : "none";
|
|
882
|
-
});
|
|
1030
|
+
}, { signal: this._ac.signal });
|
|
883
1031
|
document.addEventListener("click", () => {
|
|
884
1032
|
speedMenu.style.display = "none";
|
|
885
|
-
});
|
|
1033
|
+
}, { signal: this._ac.signal });
|
|
886
1034
|
speedMenu.addEventListener("click", (e) => {
|
|
887
1035
|
e.stopPropagation();
|
|
888
1036
|
if (e.target.classList.contains("speed-option")) {
|
|
@@ -890,11 +1038,18 @@
|
|
|
890
1038
|
this.setPlaybackRate(rate);
|
|
891
1039
|
speedMenu.style.display = "none";
|
|
892
1040
|
}
|
|
893
|
-
});
|
|
1041
|
+
}, { signal: this._ac.signal });
|
|
894
1042
|
this.updateSpeedUI();
|
|
895
1043
|
}
|
|
896
1044
|
/**
|
|
897
|
-
*
|
|
1045
|
+
* Enable keyboard transport controls on the container.
|
|
1046
|
+
*
|
|
1047
|
+
* The container is focusable only after it is clicked (it carries
|
|
1048
|
+
* `tabindex="-1"` until then, and clicking steals focus from sibling
|
|
1049
|
+
* players). While focused it handles: digits 0-9 (seek to that tenth of
|
|
1050
|
+
* the track), Space (toggle play), and — in self mode only, since
|
|
1051
|
+
* `this.audio` is null in external mode — arrow keys (seek ±5s, volume
|
|
1052
|
+
* ±0.1) and `m`/`M` (mute). Listeners use the instance abort signal.
|
|
898
1053
|
* @private
|
|
899
1054
|
*/
|
|
900
1055
|
initKeyboardControls() {
|
|
@@ -907,7 +1062,7 @@
|
|
|
907
1062
|
});
|
|
908
1063
|
this.container.setAttribute("tabindex", "0");
|
|
909
1064
|
this.container.focus();
|
|
910
|
-
});
|
|
1065
|
+
}, { signal: this._ac.signal });
|
|
911
1066
|
this.container.addEventListener("keydown", (e) => {
|
|
912
1067
|
if (document.activeElement !== this.container) return;
|
|
913
1068
|
const key = e.key;
|
|
@@ -922,17 +1077,17 @@
|
|
|
922
1077
|
" ": () => this.togglePlay()
|
|
923
1078
|
};
|
|
924
1079
|
if (hasAudio) {
|
|
925
|
-
actions["ArrowLeft"] = () => this.seekTo(
|
|
926
|
-
actions["ArrowRight"] = () => this.seekTo(
|
|
927
|
-
actions["ArrowUp"] = () => this.setVolume(
|
|
928
|
-
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));
|
|
929
1084
|
actions["m"] = actions["M"] = () => this.audio.muted = !this.audio.muted;
|
|
930
1085
|
}
|
|
931
1086
|
if (actions[key]) {
|
|
932
1087
|
e.preventDefault();
|
|
933
1088
|
actions[key]();
|
|
934
1089
|
}
|
|
935
|
-
});
|
|
1090
|
+
}, { signal: this._ac.signal });
|
|
936
1091
|
}
|
|
937
1092
|
/**
|
|
938
1093
|
* Expose the waveform as an accessible, keyboard-operable slider.
|
|
@@ -984,7 +1139,7 @@
|
|
|
984
1139
|
e.preventDefault();
|
|
985
1140
|
e.stopPropagation();
|
|
986
1141
|
this.seekToSeconds(target);
|
|
987
|
-
});
|
|
1142
|
+
}, { signal: this._ac.signal });
|
|
988
1143
|
}
|
|
989
1144
|
/**
|
|
990
1145
|
* Total seekable duration in seconds, regardless of audio mode.
|
|
@@ -1010,26 +1165,22 @@
|
|
|
1010
1165
|
}
|
|
1011
1166
|
/**
|
|
1012
1167
|
* Seek the slider to an absolute time, clamped to the track length.
|
|
1013
|
-
*
|
|
1168
|
+
*
|
|
1169
|
+
* In self mode this defers to {@link WaveformPlayer#seekTo}. In external
|
|
1170
|
+
* mode it dispatches a cancelable `waveformplayer:request-seek` event with
|
|
1171
|
+
* the target percentage; if the controller doesn't `preventDefault()`, the
|
|
1172
|
+
* local progress/visual is updated optimistically. Either way the ARIA
|
|
1173
|
+
* slider values are refreshed.
|
|
1014
1174
|
* @param {number} seconds - Target time in seconds.
|
|
1015
1175
|
* @private
|
|
1176
|
+
* @fires WaveformPlayer#waveformplayer:request-seek
|
|
1016
1177
|
*/
|
|
1017
1178
|
seekToSeconds(seconds) {
|
|
1018
1179
|
const duration = this.getSeekDuration();
|
|
1019
1180
|
if (!duration) return;
|
|
1020
|
-
const clamped =
|
|
1181
|
+
const clamped = clamp(seconds, 0, duration);
|
|
1021
1182
|
if (this.options.audioMode === "external") {
|
|
1022
|
-
|
|
1023
|
-
const evt = new CustomEvent("waveformplayer:request-seek", {
|
|
1024
|
-
bubbles: true,
|
|
1025
|
-
cancelable: true,
|
|
1026
|
-
detail: { ...this._buildTrackDetail(), percent }
|
|
1027
|
-
});
|
|
1028
|
-
this.container.dispatchEvent(evt);
|
|
1029
|
-
if (!evt.defaultPrevented) {
|
|
1030
|
-
this.progress = percent;
|
|
1031
|
-
this.drawWaveform?.();
|
|
1032
|
-
}
|
|
1183
|
+
this._requestSeek(clamped / duration);
|
|
1033
1184
|
this.updateSeekAccessibility();
|
|
1034
1185
|
return;
|
|
1035
1186
|
}
|
|
@@ -1037,7 +1188,9 @@
|
|
|
1037
1188
|
}
|
|
1038
1189
|
/**
|
|
1039
1190
|
* Set the slider's accessible name from `seekLabel`, falling back to the
|
|
1040
|
-
* track title, then a generic 'Seek'.
|
|
1191
|
+
* track title, then a generic 'Seek'. No-op if the slider isn't present.
|
|
1192
|
+
* @param {string} [title=this.options.title] - Track title to fall back to
|
|
1193
|
+
* when `seekLabel` is not set.
|
|
1041
1194
|
* @private
|
|
1042
1195
|
*/
|
|
1043
1196
|
applySeekLabel(title = this.options.title) {
|
|
@@ -1078,10 +1231,10 @@
|
|
|
1078
1231
|
navigator.mediaSession.setActionHandler("play", () => this.play());
|
|
1079
1232
|
navigator.mediaSession.setActionHandler("pause", () => this.pause());
|
|
1080
1233
|
navigator.mediaSession.setActionHandler("seekbackward", () => {
|
|
1081
|
-
this.seekTo(
|
|
1234
|
+
this.seekTo(clamp(this.audio.currentTime - 10, 0, this.audio.duration));
|
|
1082
1235
|
});
|
|
1083
1236
|
navigator.mediaSession.setActionHandler("seekforward", () => {
|
|
1084
|
-
this.seekTo(
|
|
1237
|
+
this.seekTo(clamp(this.audio.currentTime + 10, 0, this.audio.duration));
|
|
1085
1238
|
});
|
|
1086
1239
|
navigator.mediaSession.setActionHandler("seekto", (details) => {
|
|
1087
1240
|
if (details.seekTime !== null) {
|
|
@@ -1093,7 +1246,10 @@
|
|
|
1093
1246
|
// Event Binding
|
|
1094
1247
|
// ============================================
|
|
1095
1248
|
/**
|
|
1096
|
-
* Bind
|
|
1249
|
+
* Bind the core interaction listeners: play-button click, the `<audio>`
|
|
1250
|
+
* media events (self mode only — external mode is fed state via
|
|
1251
|
+
* {@link WaveformPlayer#setPlayingState}/{@link WaveformPlayer#setProgress}),
|
|
1252
|
+
* canvas click-to-seek, and a debounced window-resize redraw.
|
|
1097
1253
|
* @private
|
|
1098
1254
|
*/
|
|
1099
1255
|
bindEvents() {
|
|
@@ -1114,7 +1270,8 @@
|
|
|
1114
1270
|
window.addEventListener("resize", this.resizeHandler);
|
|
1115
1271
|
}
|
|
1116
1272
|
/**
|
|
1117
|
-
*
|
|
1273
|
+
* Observe the canvas's parent element for size changes and re-fit the
|
|
1274
|
+
* canvas on each one. No-op where `ResizeObserver` is unavailable.
|
|
1118
1275
|
* @private
|
|
1119
1276
|
*/
|
|
1120
1277
|
setupResizeObserver() {
|
|
@@ -1131,9 +1288,20 @@
|
|
|
1131
1288
|
// Audio Loading
|
|
1132
1289
|
// ============================================
|
|
1133
1290
|
/**
|
|
1134
|
-
* Load audio
|
|
1135
|
-
*
|
|
1136
|
-
*
|
|
1291
|
+
* Load an audio source: set the title, fetch/generate the waveform peaks,
|
|
1292
|
+
* draw them, render markers, and initialise Media Session.
|
|
1293
|
+
*
|
|
1294
|
+
* In self mode the `<audio>` src is assigned and the method awaits
|
|
1295
|
+
* `loadedmetadata` before proceeding. In external mode there is no audio
|
|
1296
|
+
* element, so the src/metadata step is skipped and only the visualization
|
|
1297
|
+
* is built (duration/time come from the controller via
|
|
1298
|
+
* {@link WaveformPlayer#setProgress}). Peaks come from the `waveform`
|
|
1299
|
+
* option when provided, otherwise they are decoded from the audio; a
|
|
1300
|
+
* decode failure falls back to a placeholder waveform. The `onLoad`
|
|
1301
|
+
* callback fires on success.
|
|
1302
|
+
* @param {string} url - Audio URL.
|
|
1303
|
+
* @returns {Promise<void>} Resolves once loading settles (errors are caught
|
|
1304
|
+
* internally and surfaced through {@link WaveformPlayer#onError}).
|
|
1137
1305
|
*/
|
|
1138
1306
|
async load(url) {
|
|
1139
1307
|
try {
|
|
@@ -1173,7 +1341,7 @@
|
|
|
1173
1341
|
this.updateBPMDisplay();
|
|
1174
1342
|
}
|
|
1175
1343
|
} catch (error) {
|
|
1176
|
-
console.warn("Using placeholder waveform:", error);
|
|
1344
|
+
console.warn("[WaveformPlayer] Using placeholder waveform:", error);
|
|
1177
1345
|
this.waveformData = generatePlaceholderWaveform(this.options.samples);
|
|
1178
1346
|
}
|
|
1179
1347
|
}
|
|
@@ -1184,18 +1352,26 @@
|
|
|
1184
1352
|
this.options.onLoad(this);
|
|
1185
1353
|
}
|
|
1186
1354
|
} catch (error) {
|
|
1187
|
-
console.error("Failed to load audio:", error);
|
|
1188
1355
|
this.onError(error);
|
|
1189
1356
|
} finally {
|
|
1190
1357
|
this.setLoading(false);
|
|
1191
1358
|
}
|
|
1192
1359
|
}
|
|
1193
1360
|
/**
|
|
1194
|
-
*
|
|
1195
|
-
*
|
|
1196
|
-
*
|
|
1197
|
-
*
|
|
1198
|
-
*
|
|
1361
|
+
* Swap the player to a new track at runtime.
|
|
1362
|
+
*
|
|
1363
|
+
* Pauses any current playback, fully resets the audio element (self mode),
|
|
1364
|
+
* clears error/marker/progress state, merges the new metadata into
|
|
1365
|
+
* `this.options`, updates the subtitle/artwork DOM, then calls
|
|
1366
|
+
* {@link WaveformPlayer#load}. Auto-plays the new track unless
|
|
1367
|
+
* `options.autoplay === false`.
|
|
1368
|
+
* @param {string} url - Audio URL.
|
|
1369
|
+
* @param {string|null} [title=null] - Track title; keeps the existing
|
|
1370
|
+
* title when null.
|
|
1371
|
+
* @param {string|null} [subtitle=null] - Track subtitle; pass `''` to hide
|
|
1372
|
+
* the subtitle row, or null to keep the existing one.
|
|
1373
|
+
* @param {Object} [options={}] - Additional options to merge (e.g.
|
|
1374
|
+
* `preload`, `artwork`, `markers`, `autoplay`).
|
|
1199
1375
|
* @returns {Promise<void>}
|
|
1200
1376
|
*/
|
|
1201
1377
|
async loadTrack(url, title = null, subtitle = null, options = {}) {
|
|
@@ -1240,14 +1416,24 @@
|
|
|
1240
1416
|
}
|
|
1241
1417
|
this.options.markers = options.markers || [];
|
|
1242
1418
|
await this.load(url);
|
|
1243
|
-
|
|
1244
|
-
|
|
1419
|
+
if (options.autoplay !== false) {
|
|
1420
|
+
this.play()?.catch(() => {
|
|
1421
|
+
});
|
|
1422
|
+
}
|
|
1245
1423
|
}
|
|
1246
1424
|
// ============================================
|
|
1247
1425
|
// Visualization
|
|
1248
1426
|
// ============================================
|
|
1249
1427
|
/**
|
|
1250
|
-
*
|
|
1428
|
+
* Normalise externally-supplied waveform data into `this.waveformData` and
|
|
1429
|
+
* redraw.
|
|
1430
|
+
*
|
|
1431
|
+
* Accepts several shapes: a `.json` URL (fetched async; peaks and any
|
|
1432
|
+
* embedded `markers` are applied on resolve), a JSON-encoded array string,
|
|
1433
|
+
* a comma-separated number string, or a plain number array. Malformed
|
|
1434
|
+
* input degrades to an empty array rather than throwing.
|
|
1435
|
+
* @param {string|number[]} data - Peaks as an array, a JSON/CSV string, or
|
|
1436
|
+
* a URL to a `.json` peaks file.
|
|
1251
1437
|
* @private
|
|
1252
1438
|
*/
|
|
1253
1439
|
setWaveformData(data) {
|
|
@@ -1276,7 +1462,9 @@
|
|
|
1276
1462
|
this.drawWaveform();
|
|
1277
1463
|
}
|
|
1278
1464
|
/**
|
|
1279
|
-
*
|
|
1465
|
+
* Render the current waveform + progress to the canvas via the shared
|
|
1466
|
+
* {@link draw} routine, passing the resolved style and colours. No-op
|
|
1467
|
+
* before the context exists or while there is no peak data.
|
|
1280
1468
|
* @private
|
|
1281
1469
|
*/
|
|
1282
1470
|
drawWaveform() {
|
|
@@ -1289,7 +1477,9 @@
|
|
|
1289
1477
|
});
|
|
1290
1478
|
}
|
|
1291
1479
|
/**
|
|
1292
|
-
*
|
|
1480
|
+
* Re-fit the canvas backing store to its parent's width and the configured
|
|
1481
|
+
* height, scaled by the device pixel ratio for crisp rendering, then
|
|
1482
|
+
* redraw. Guards against running after destruction.
|
|
1293
1483
|
* @private
|
|
1294
1484
|
*/
|
|
1295
1485
|
resizeCanvas() {
|
|
@@ -1304,22 +1494,31 @@
|
|
|
1304
1494
|
this.drawWaveform();
|
|
1305
1495
|
}
|
|
1306
1496
|
/**
|
|
1307
|
-
* Render markers
|
|
1497
|
+
* Render the configured cue markers as positioned, clickable buttons over
|
|
1498
|
+
* the waveform.
|
|
1499
|
+
*
|
|
1500
|
+
* Clears any existing markers first, then bails out unless `showMarkers` is
|
|
1501
|
+
* on, markers exist, and a duration is known (via the mode-agnostic
|
|
1502
|
+
* {@link WaveformPlayer#getSeekDuration}). Each marker is placed by its
|
|
1503
|
+
* time-as-percentage, carries a tooltip and ARIA label, and seeks on click
|
|
1504
|
+
* (also starting playback when `playOnSeek` is set and currently paused).
|
|
1505
|
+
* Markers past the track duration are skipped with a warning.
|
|
1308
1506
|
* @private
|
|
1309
1507
|
*/
|
|
1310
1508
|
renderMarkers() {
|
|
1311
1509
|
if (!this.markersContainer) return;
|
|
1312
1510
|
this.markersContainer.innerHTML = "";
|
|
1313
1511
|
if (!this.options.showMarkers || !this.options.markers?.length) return;
|
|
1314
|
-
|
|
1512
|
+
const duration = this.getSeekDuration();
|
|
1513
|
+
if (!duration) {
|
|
1315
1514
|
return;
|
|
1316
1515
|
}
|
|
1317
1516
|
this.options.markers.forEach((marker, index) => {
|
|
1318
|
-
if (marker.time >
|
|
1319
|
-
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`);
|
|
1320
1519
|
return;
|
|
1321
1520
|
}
|
|
1322
|
-
const position = marker.time /
|
|
1521
|
+
const position = marker.time / duration * 100;
|
|
1323
1522
|
const markerEl = document.createElement("button");
|
|
1324
1523
|
markerEl.className = "waveform-marker";
|
|
1325
1524
|
markerEl.style.left = `${position}%`;
|
|
@@ -1340,35 +1539,49 @@
|
|
|
1340
1539
|
this.markersContainer.appendChild(markerEl);
|
|
1341
1540
|
});
|
|
1342
1541
|
}
|
|
1542
|
+
/**
|
|
1543
|
+
* Highlight the marker at `index` (toggling an `active` class) and clear
|
|
1544
|
+
* the rest. Pass `null` to clear all. Lets an external controller (e.g. a
|
|
1545
|
+
* DJ bar) reflect the current section without reaching into the player's
|
|
1546
|
+
* private marker DOM.
|
|
1547
|
+
* @param {number|null} index - Marker index to activate, or `null` to clear.
|
|
1548
|
+
*/
|
|
1549
|
+
setActiveMarker(index) {
|
|
1550
|
+
if (!this.markersContainer) return;
|
|
1551
|
+
const markers = this.markersContainer.querySelectorAll(".waveform-marker");
|
|
1552
|
+
markers.forEach((el, i) => el.classList.toggle("active", i === index));
|
|
1553
|
+
}
|
|
1343
1554
|
// ============================================
|
|
1344
1555
|
// Event Handlers
|
|
1345
1556
|
// ============================================
|
|
1346
1557
|
/**
|
|
1347
|
-
*
|
|
1558
|
+
* Seek to the clicked horizontal position on the waveform canvas.
|
|
1559
|
+
*
|
|
1560
|
+
* Converts the click X into a 0..1 percentage. In external mode it
|
|
1561
|
+
* dispatches a cancelable `waveformplayer:request-seek` event (updating the
|
|
1562
|
+
* local visual optimistically unless the controller vetoes it); in self
|
|
1563
|
+
* mode it seeks the owned `<audio>` via
|
|
1564
|
+
* {@link WaveformPlayer#seekToPercent}.
|
|
1565
|
+
* @param {MouseEvent} event - The canvas click event.
|
|
1348
1566
|
* @private
|
|
1567
|
+
* @fires WaveformPlayer#waveformplayer:request-seek
|
|
1349
1568
|
*/
|
|
1350
1569
|
handleCanvasClick(event) {
|
|
1351
1570
|
const rect = this.canvas.getBoundingClientRect();
|
|
1352
1571
|
const x = event.clientX - rect.left;
|
|
1353
|
-
const targetPercent =
|
|
1572
|
+
const targetPercent = clamp(x / rect.width);
|
|
1354
1573
|
if (this.options.audioMode === "external") {
|
|
1355
|
-
|
|
1356
|
-
bubbles: true,
|
|
1357
|
-
cancelable: true,
|
|
1358
|
-
detail: { ...this._buildTrackDetail(), percent: targetPercent }
|
|
1359
|
-
});
|
|
1360
|
-
this.container.dispatchEvent(evt);
|
|
1361
|
-
if (!evt.defaultPrevented) {
|
|
1362
|
-
this.progress = targetPercent;
|
|
1363
|
-
this.drawWaveform?.();
|
|
1364
|
-
}
|
|
1574
|
+
this._requestSeek(targetPercent);
|
|
1365
1575
|
return;
|
|
1366
1576
|
}
|
|
1367
1577
|
if (!this.audio || !this.audio.duration) return;
|
|
1368
1578
|
this.seekToPercent(targetPercent);
|
|
1369
1579
|
}
|
|
1370
1580
|
/**
|
|
1371
|
-
*
|
|
1581
|
+
* Toggle the loading state: show/hide the spinner overlay and set
|
|
1582
|
+
* `aria-busy` on the accessible seek slider so assistive tech knows the
|
|
1583
|
+
* player is fetching/decoding.
|
|
1584
|
+
* @param {boolean} loading - True while audio is loading.
|
|
1372
1585
|
* @private
|
|
1373
1586
|
*/
|
|
1374
1587
|
setLoading(loading) {
|
|
@@ -1376,9 +1589,14 @@
|
|
|
1376
1589
|
if (this.loadingEl) {
|
|
1377
1590
|
this.loadingEl.style.display = loading ? "block" : "none";
|
|
1378
1591
|
}
|
|
1592
|
+
if (this.seekEl) {
|
|
1593
|
+
this.seekEl.setAttribute("aria-busy", loading ? "true" : "false");
|
|
1594
|
+
}
|
|
1379
1595
|
}
|
|
1380
1596
|
/**
|
|
1381
|
-
*
|
|
1597
|
+
* `loadedmetadata` handler (self mode): write the total-time display, now
|
|
1598
|
+
* that duration is known re-render markers, and publish duration to the
|
|
1599
|
+
* accessible seek slider. No-op during destruction.
|
|
1382
1600
|
* @private
|
|
1383
1601
|
*/
|
|
1384
1602
|
onMetadataLoaded() {
|
|
@@ -1390,79 +1608,91 @@
|
|
|
1390
1608
|
this.updateSeekAccessibility();
|
|
1391
1609
|
}
|
|
1392
1610
|
/**
|
|
1393
|
-
*
|
|
1611
|
+
* Reflect play/pause state on the transport button: toggle the `playing`
|
|
1612
|
+
* class and swap the play/pause icon visibility. The single source of
|
|
1613
|
+
* truth shared by `onPlay`, `onPause`, and the external-mode
|
|
1614
|
+
* `setPlayingState` pump so they can't drift. No-op without a button.
|
|
1615
|
+
* @param {boolean} isPlaying - Whether playback is active.
|
|
1394
1616
|
* @private
|
|
1395
1617
|
*/
|
|
1618
|
+
setPlayButtonState(isPlaying) {
|
|
1619
|
+
if (!this.playBtn) return;
|
|
1620
|
+
this.playBtn.classList.toggle("playing", isPlaying);
|
|
1621
|
+
const playIcon = this.playBtn.querySelector(".waveform-icon-play");
|
|
1622
|
+
const pauseIcon = this.playBtn.querySelector(".waveform-icon-pause");
|
|
1623
|
+
if (playIcon) playIcon.style.display = isPlaying ? "none" : "flex";
|
|
1624
|
+
if (pauseIcon) pauseIcon.style.display = isPlaying ? "flex" : "none";
|
|
1625
|
+
}
|
|
1626
|
+
/**
|
|
1627
|
+
* `play` handler (self mode): set the playing flag, swap the button to its
|
|
1628
|
+
* pause icon, start the smooth progress loop, dispatch
|
|
1629
|
+
* `waveformplayer:play`, and fire the `onPlay` callback. No-op during
|
|
1630
|
+
* destruction.
|
|
1631
|
+
* @private
|
|
1632
|
+
* @fires WaveformPlayer#waveformplayer:play
|
|
1633
|
+
*/
|
|
1396
1634
|
onPlay() {
|
|
1397
1635
|
if (this.isDestroying) return;
|
|
1398
1636
|
this.isPlaying = true;
|
|
1399
|
-
|
|
1400
|
-
this.playBtn.classList.add("playing");
|
|
1401
|
-
const playIcon = this.playBtn.querySelector(".waveform-icon-play");
|
|
1402
|
-
const pauseIcon = this.playBtn.querySelector(".waveform-icon-pause");
|
|
1403
|
-
if (playIcon) playIcon.style.display = "none";
|
|
1404
|
-
if (pauseIcon) pauseIcon.style.display = "flex";
|
|
1405
|
-
}
|
|
1637
|
+
this.setPlayButtonState(true);
|
|
1406
1638
|
this.startSmoothUpdate();
|
|
1407
|
-
this.
|
|
1408
|
-
bubbles: true,
|
|
1409
|
-
detail: { player: this, url: this.options.url }
|
|
1410
|
-
}));
|
|
1639
|
+
this._emit("waveformplayer:play", { player: this, url: this.options.url });
|
|
1411
1640
|
if (this.options.onPlay) {
|
|
1412
1641
|
this.options.onPlay(this);
|
|
1413
1642
|
}
|
|
1414
1643
|
}
|
|
1415
1644
|
/**
|
|
1416
|
-
*
|
|
1645
|
+
* `pause` handler (self mode): clear the playing flag, swap the button back
|
|
1646
|
+
* to its play icon, stop the smooth progress loop, dispatch
|
|
1647
|
+
* `waveformplayer:pause`, and fire the `onPause` callback. No-op during
|
|
1648
|
+
* destruction.
|
|
1417
1649
|
* @private
|
|
1650
|
+
* @fires WaveformPlayer#waveformplayer:pause
|
|
1418
1651
|
*/
|
|
1419
1652
|
onPause() {
|
|
1420
1653
|
if (this.isDestroying) return;
|
|
1421
1654
|
this.isPlaying = false;
|
|
1422
|
-
|
|
1423
|
-
this.playBtn.classList.remove("playing");
|
|
1424
|
-
const playIcon = this.playBtn.querySelector(".waveform-icon-play");
|
|
1425
|
-
const pauseIcon = this.playBtn.querySelector(".waveform-icon-pause");
|
|
1426
|
-
if (playIcon) playIcon.style.display = "flex";
|
|
1427
|
-
if (pauseIcon) pauseIcon.style.display = "none";
|
|
1428
|
-
}
|
|
1655
|
+
this.setPlayButtonState(false);
|
|
1429
1656
|
this.stopSmoothUpdate();
|
|
1430
|
-
this.
|
|
1431
|
-
bubbles: true,
|
|
1432
|
-
detail: { player: this, url: this.options.url }
|
|
1433
|
-
}));
|
|
1657
|
+
this._emit("waveformplayer:pause", { player: this, url: this.options.url });
|
|
1434
1658
|
if (this.options.onPause) {
|
|
1435
1659
|
this.options.onPause(this);
|
|
1436
1660
|
}
|
|
1437
1661
|
}
|
|
1438
1662
|
/**
|
|
1439
|
-
*
|
|
1663
|
+
* `ended` handler (self mode): reset progress and `currentTime` to the
|
|
1664
|
+
* start, redraw, reset the time display, dispatch `waveformplayer:ended`
|
|
1665
|
+
* (carrying the final time), run {@link WaveformPlayer#onPause}, and fire
|
|
1666
|
+
* the `onEnd` callback. No-op during destruction.
|
|
1440
1667
|
* @private
|
|
1668
|
+
* @fires WaveformPlayer#waveformplayer:ended
|
|
1441
1669
|
*/
|
|
1442
1670
|
onEnded() {
|
|
1443
1671
|
if (this.isDestroying) return;
|
|
1672
|
+
const duration = this.audio.duration;
|
|
1444
1673
|
this.progress = 0;
|
|
1445
1674
|
this.audio.currentTime = 0;
|
|
1446
1675
|
this.drawWaveform();
|
|
1447
1676
|
if (this.currentTimeEl) {
|
|
1448
1677
|
this.currentTimeEl.textContent = "0:00";
|
|
1449
1678
|
}
|
|
1450
|
-
this.
|
|
1451
|
-
bubbles: true,
|
|
1452
|
-
detail: { player: this, url: this.options.url }
|
|
1453
|
-
}));
|
|
1679
|
+
this._emit("waveformplayer:ended", { player: this, url: this.options.url, currentTime: duration, duration });
|
|
1454
1680
|
this.onPause();
|
|
1455
1681
|
if (this.options.onEnd) {
|
|
1456
1682
|
this.options.onEnd(this);
|
|
1457
1683
|
}
|
|
1458
1684
|
}
|
|
1459
1685
|
/**
|
|
1460
|
-
*
|
|
1686
|
+
* `error` handler: set the error flag, hide the spinner, reveal the error
|
|
1687
|
+
* overlay, dim the canvas, disable the play button, and fire the `onError`
|
|
1688
|
+
* callback. No-op during destruction.
|
|
1689
|
+
* @param {Event|Error} error - The audio error event, or an Error thrown
|
|
1690
|
+
* during loading.
|
|
1461
1691
|
* @private
|
|
1462
1692
|
*/
|
|
1463
1693
|
onError(error) {
|
|
1464
1694
|
if (this.isDestroying) return;
|
|
1465
|
-
console.error("Audio error:", error);
|
|
1695
|
+
console.error("[WaveformPlayer] Audio error:", error);
|
|
1466
1696
|
this.hasError = true;
|
|
1467
1697
|
this.setLoading(false);
|
|
1468
1698
|
if (this.errorEl) {
|
|
@@ -1482,7 +1712,10 @@
|
|
|
1482
1712
|
// Progress Updates
|
|
1483
1713
|
// ============================================
|
|
1484
1714
|
/**
|
|
1485
|
-
* Start smooth
|
|
1715
|
+
* Start the `requestAnimationFrame` loop that drives smooth progress
|
|
1716
|
+
* updates while playing (self mode only — external mode is redrawn by
|
|
1717
|
+
* controller {@link WaveformPlayer#setProgress} pushes). Cancels any
|
|
1718
|
+
* existing loop first so it's safe to call repeatedly.
|
|
1486
1719
|
* @private
|
|
1487
1720
|
*/
|
|
1488
1721
|
startSmoothUpdate() {
|
|
@@ -1496,7 +1729,7 @@
|
|
|
1496
1729
|
this.updateTimer = requestAnimationFrame(update);
|
|
1497
1730
|
}
|
|
1498
1731
|
/**
|
|
1499
|
-
*
|
|
1732
|
+
* Cancel the smooth-update animation frame, if one is scheduled.
|
|
1500
1733
|
* @private
|
|
1501
1734
|
*/
|
|
1502
1735
|
stopSmoothUpdate() {
|
|
@@ -1506,8 +1739,15 @@
|
|
|
1506
1739
|
}
|
|
1507
1740
|
}
|
|
1508
1741
|
/**
|
|
1509
|
-
*
|
|
1742
|
+
* Recompute progress from the owned `<audio>` clock and reflect it
|
|
1743
|
+
* everywhere (self mode only — external mode uses
|
|
1744
|
+
* {@link WaveformPlayer#setProgress}).
|
|
1745
|
+
*
|
|
1746
|
+
* Redraws the canvas when progress moves meaningfully, updates the
|
|
1747
|
+
* current-time display, dispatches `waveformplayer:timeupdate`, fires the
|
|
1748
|
+
* `onTimeUpdate` callback, and refreshes the accessible slider values.
|
|
1510
1749
|
* @private
|
|
1750
|
+
* @fires WaveformPlayer#waveformplayer:timeupdate
|
|
1511
1751
|
*/
|
|
1512
1752
|
updateProgress() {
|
|
1513
1753
|
if (!this.audio || !this.audio.duration) return;
|
|
@@ -1519,15 +1759,13 @@
|
|
|
1519
1759
|
if (this.currentTimeEl) {
|
|
1520
1760
|
this.currentTimeEl.textContent = formatTime(this.audio.currentTime);
|
|
1521
1761
|
}
|
|
1522
|
-
this.
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
}
|
|
1530
|
-
}));
|
|
1762
|
+
this._emit("waveformplayer:timeupdate", {
|
|
1763
|
+
player: this,
|
|
1764
|
+
currentTime: this.audio.currentTime,
|
|
1765
|
+
duration: this.audio.duration,
|
|
1766
|
+
progress: this.progress,
|
|
1767
|
+
url: this.options.url
|
|
1768
|
+
});
|
|
1531
1769
|
if (this.options.onTimeUpdate) {
|
|
1532
1770
|
this.options.onTimeUpdate(this.audio.currentTime, this.audio.duration, this);
|
|
1533
1771
|
}
|
|
@@ -1537,7 +1775,7 @@
|
|
|
1537
1775
|
// UI Updates
|
|
1538
1776
|
// ============================================
|
|
1539
1777
|
/**
|
|
1540
|
-
*
|
|
1778
|
+
* Show the detected BPM in the badge, once a value has been detected.
|
|
1541
1779
|
* @private
|
|
1542
1780
|
*/
|
|
1543
1781
|
updateBPMDisplay() {
|
|
@@ -1547,10 +1785,14 @@
|
|
|
1547
1785
|
}
|
|
1548
1786
|
}
|
|
1549
1787
|
/**
|
|
1550
|
-
*
|
|
1788
|
+
* Sync the speed control's label and the menu's active-option highlight to
|
|
1789
|
+
* the audio element's current `playbackRate`. No-op in external mode (no
|
|
1790
|
+
* owned `<audio>`), which also avoids reading `playbackRate` before the
|
|
1791
|
+
* element exists.
|
|
1551
1792
|
* @private
|
|
1552
1793
|
*/
|
|
1553
1794
|
updateSpeedUI() {
|
|
1795
|
+
if (!this.audio) return;
|
|
1554
1796
|
const speedValue = this.container.querySelector(".speed-value");
|
|
1555
1797
|
if (speedValue) {
|
|
1556
1798
|
const rate = this.audio.playbackRate;
|
|
@@ -1577,19 +1819,19 @@
|
|
|
1577
1819
|
* setPlayingState() / setProgress(). Calling preventDefault() on
|
|
1578
1820
|
* the event lets the controller veto the play (state is unchanged).
|
|
1579
1821
|
*
|
|
1580
|
-
*
|
|
1822
|
+
* When `singlePlay` is enabled, any other currently-playing instance is
|
|
1823
|
+
* paused first.
|
|
1824
|
+
*
|
|
1825
|
+
* @return {Promise|undefined} The promise from `HTMLMediaElement.play()` in
|
|
1826
|
+
* self mode; `undefined` in external mode.
|
|
1827
|
+
* @fires WaveformPlayer#waveformplayer:request-play
|
|
1581
1828
|
*/
|
|
1582
1829
|
play() {
|
|
1583
1830
|
if (this.options.singlePlay && _WaveformPlayer.currentlyPlaying && _WaveformPlayer.currentlyPlaying !== this) {
|
|
1584
1831
|
_WaveformPlayer.currentlyPlaying.pause();
|
|
1585
1832
|
}
|
|
1586
1833
|
if (this.options.audioMode === "external") {
|
|
1587
|
-
const evt =
|
|
1588
|
-
bubbles: true,
|
|
1589
|
-
cancelable: true,
|
|
1590
|
-
detail: this._buildTrackDetail()
|
|
1591
|
-
});
|
|
1592
|
-
this.container.dispatchEvent(evt);
|
|
1834
|
+
const evt = this._emit("waveformplayer:request-play", this._buildTrackDetail(), true);
|
|
1593
1835
|
if (!evt.defaultPrevented) {
|
|
1594
1836
|
_WaveformPlayer.currentlyPlaying = this;
|
|
1595
1837
|
}
|
|
@@ -1603,17 +1845,15 @@
|
|
|
1603
1845
|
*
|
|
1604
1846
|
* In `audioMode: 'external'`, dispatches `waveformplayer:request-pause`
|
|
1605
1847
|
* (cancelable) and does NOT touch any audio element. See play().
|
|
1848
|
+
*
|
|
1849
|
+
* @fires WaveformPlayer#waveformplayer:request-pause
|
|
1606
1850
|
*/
|
|
1607
1851
|
pause() {
|
|
1608
1852
|
if (_WaveformPlayer.currentlyPlaying === this) {
|
|
1609
1853
|
_WaveformPlayer.currentlyPlaying = null;
|
|
1610
1854
|
}
|
|
1611
1855
|
if (this.options.audioMode === "external") {
|
|
1612
|
-
this.
|
|
1613
|
-
bubbles: true,
|
|
1614
|
-
cancelable: true,
|
|
1615
|
-
detail: this._buildTrackDetail()
|
|
1616
|
-
}));
|
|
1856
|
+
this._emit("waveformplayer:request-pause", this._buildTrackDetail(), true);
|
|
1617
1857
|
return;
|
|
1618
1858
|
}
|
|
1619
1859
|
this.audio.pause();
|
|
@@ -1632,8 +1872,12 @@
|
|
|
1632
1872
|
url: this.options.url,
|
|
1633
1873
|
title: this.options.title,
|
|
1634
1874
|
subtitle: this.options.subtitle,
|
|
1635
|
-
artist
|
|
1875
|
+
// Core has no separate `artist` option; mirror subtitle so the
|
|
1876
|
+
// published event detail is self-consistent for controllers.
|
|
1877
|
+
artist: this.options.artist || this.options.subtitle,
|
|
1636
1878
|
artwork: this.options.artwork,
|
|
1879
|
+
markers: this.options.markers,
|
|
1880
|
+
waveform: this.options.waveform,
|
|
1637
1881
|
id: this.id,
|
|
1638
1882
|
player: this
|
|
1639
1883
|
};
|
|
@@ -1643,31 +1887,26 @@
|
|
|
1643
1887
|
* touching audio. Mirrors what onPlay()/onPause() do but skips the
|
|
1644
1888
|
* audio-element interactions. Safe to call repeatedly — idempotent.
|
|
1645
1889
|
*
|
|
1646
|
-
*
|
|
1890
|
+
* Only dispatches `waveformplayer:play`/`waveformplayer:pause` (and runs
|
|
1891
|
+
* the matching callback) on an actual transition, starting/stopping the
|
|
1892
|
+
* smooth-update loop accordingly.
|
|
1893
|
+
*
|
|
1894
|
+
* @param {boolean} playing - True to enter the playing state, false to
|
|
1895
|
+
* enter the paused state.
|
|
1896
|
+
* @fires WaveformPlayer#waveformplayer:play
|
|
1897
|
+
* @fires WaveformPlayer#waveformplayer:pause
|
|
1647
1898
|
*/
|
|
1648
1899
|
setPlayingState(playing) {
|
|
1649
1900
|
const wasPlaying = this.isPlaying;
|
|
1650
1901
|
this.isPlaying = !!playing;
|
|
1651
|
-
|
|
1652
|
-
this.playBtn.classList.toggle("playing", this.isPlaying);
|
|
1653
|
-
const playIcon = this.playBtn.querySelector(".waveform-icon-play");
|
|
1654
|
-
const pauseIcon = this.playBtn.querySelector(".waveform-icon-pause");
|
|
1655
|
-
if (playIcon) playIcon.style.display = this.isPlaying ? "none" : "flex";
|
|
1656
|
-
if (pauseIcon) pauseIcon.style.display = this.isPlaying ? "flex" : "none";
|
|
1657
|
-
}
|
|
1902
|
+
this.setPlayButtonState(this.isPlaying);
|
|
1658
1903
|
if (this.isPlaying && !wasPlaying) {
|
|
1659
1904
|
this.startSmoothUpdate?.();
|
|
1660
|
-
this.
|
|
1661
|
-
bubbles: true,
|
|
1662
|
-
detail: { player: this, url: this.options.url }
|
|
1663
|
-
}));
|
|
1905
|
+
this._emit("waveformplayer:play", { player: this, url: this.options.url });
|
|
1664
1906
|
if (this.options.onPlay) this.options.onPlay(this);
|
|
1665
1907
|
} else if (!this.isPlaying && wasPlaying) {
|
|
1666
1908
|
this.stopSmoothUpdate?.();
|
|
1667
|
-
this.
|
|
1668
|
-
bubbles: true,
|
|
1669
|
-
detail: { player: this, url: this.options.url }
|
|
1670
|
-
}));
|
|
1909
|
+
this._emit("waveformplayer:pause", { player: this, url: this.options.url });
|
|
1671
1910
|
if (this.options.onPause) this.options.onPause(this);
|
|
1672
1911
|
}
|
|
1673
1912
|
}
|
|
@@ -1676,28 +1915,45 @@
|
|
|
1676
1915
|
* from an external clock (e.g. WaveformBar's audio element's
|
|
1677
1916
|
* timeupdate). Drives the canvas redraw + the time displays.
|
|
1678
1917
|
*
|
|
1679
|
-
*
|
|
1680
|
-
*
|
|
1918
|
+
* Redraws the canvas, updates the current/total time displays, stores the
|
|
1919
|
+
* external duration for the accessible slider, dispatches
|
|
1920
|
+
* `waveformplayer:timeupdate`, runs `onTimeUpdate`, and synthesizes a
|
|
1921
|
+
* one-shot `waveformplayer:ended` (with `onEnd`) when progress reaches the
|
|
1922
|
+
* end. No-op for a non-positive duration.
|
|
1923
|
+
*
|
|
1924
|
+
* @param {number} currentTime - Current playback position in seconds.
|
|
1925
|
+
* @param {number} duration - Total track duration in seconds.
|
|
1926
|
+
* @fires WaveformPlayer#waveformplayer:timeupdate
|
|
1927
|
+
* @fires WaveformPlayer#waveformplayer:ended
|
|
1681
1928
|
*/
|
|
1682
1929
|
setProgress(currentTime, duration) {
|
|
1683
1930
|
if (!duration || duration <= 0) return;
|
|
1684
|
-
this.progress =
|
|
1931
|
+
this.progress = clamp(currentTime / duration);
|
|
1685
1932
|
if (this.currentTimeEl) this.currentTimeEl.textContent = formatTime(currentTime);
|
|
1686
|
-
|
|
1933
|
+
this._extDuration = duration;
|
|
1934
|
+
if (this.totalTimeEl && (!this.totalTimeEl.dataset._extSet || this.totalTimeEl.dataset._extDur !== String(duration))) {
|
|
1687
1935
|
this.totalTimeEl.textContent = formatTime(duration);
|
|
1688
1936
|
this.totalTimeEl.dataset._extSet = "1";
|
|
1689
|
-
this.
|
|
1937
|
+
this.totalTimeEl.dataset._extDur = String(duration);
|
|
1690
1938
|
}
|
|
1691
1939
|
this.drawWaveform?.();
|
|
1692
|
-
this.
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1940
|
+
this._emit("waveformplayer:timeupdate", { player: this, currentTime, duration, progress: this.progress, url: this.options.url });
|
|
1941
|
+
if (this.options.onTimeUpdate) this.options.onTimeUpdate(currentTime, duration, this);
|
|
1942
|
+
if (this.progress >= 1) {
|
|
1943
|
+
if (!this._extEnded) {
|
|
1944
|
+
this._extEnded = true;
|
|
1945
|
+
this._emit("waveformplayer:ended", { player: this, url: this.options.url, currentTime: duration, duration });
|
|
1946
|
+
if (this.options.onEnd) this.options.onEnd(this);
|
|
1947
|
+
}
|
|
1948
|
+
} else {
|
|
1949
|
+
this._extEnded = false;
|
|
1950
|
+
}
|
|
1697
1951
|
this.updateSeekAccessibility();
|
|
1698
1952
|
}
|
|
1699
1953
|
/**
|
|
1700
|
-
* Toggle play
|
|
1954
|
+
* Toggle between play and pause based on the current `isPlaying` state.
|
|
1955
|
+
* Works in both audio modes (in external mode it routes through the
|
|
1956
|
+
* request-play/pause events).
|
|
1701
1957
|
*/
|
|
1702
1958
|
togglePlay() {
|
|
1703
1959
|
if (this.isPlaying) {
|
|
@@ -1707,52 +1963,71 @@
|
|
|
1707
1963
|
}
|
|
1708
1964
|
}
|
|
1709
1965
|
/**
|
|
1710
|
-
* Seek to time
|
|
1711
|
-
*
|
|
1966
|
+
* Seek the owned `<audio>` element to an absolute time, clamped to
|
|
1967
|
+
* `[0, duration]`, and refresh progress. Self mode only — a no-op when
|
|
1968
|
+
* there is no audio element or duration. External-mode keyboard/click
|
|
1969
|
+
* seeks go through {@link WaveformPlayer#seekToSeconds} instead.
|
|
1970
|
+
* @param {number} seconds - Target time in seconds.
|
|
1712
1971
|
*/
|
|
1713
1972
|
seekTo(seconds) {
|
|
1714
1973
|
if (this.audio && this.audio.duration) {
|
|
1715
|
-
this.audio.currentTime =
|
|
1974
|
+
this.audio.currentTime = clamp(seconds, 0, this.audio.duration);
|
|
1716
1975
|
this.updateProgress();
|
|
1717
1976
|
}
|
|
1718
1977
|
}
|
|
1719
1978
|
/**
|
|
1720
|
-
* Seek to
|
|
1721
|
-
*
|
|
1979
|
+
* Seek the owned `<audio>` element to a fraction of the track, clamped to
|
|
1980
|
+
* `[0, 1]`, and refresh progress. Self mode only — a no-op without an audio
|
|
1981
|
+
* element or duration.
|
|
1982
|
+
* @param {number} percent - Position as a fraction from 0 to 1.
|
|
1722
1983
|
*/
|
|
1723
1984
|
seekToPercent(percent) {
|
|
1724
1985
|
if (this.audio && this.audio.duration) {
|
|
1725
|
-
this.audio.currentTime = this.audio.duration *
|
|
1986
|
+
this.audio.currentTime = this.audio.duration * clamp(percent);
|
|
1726
1987
|
this.updateProgress();
|
|
1727
1988
|
}
|
|
1728
1989
|
}
|
|
1729
1990
|
/**
|
|
1730
|
-
* Set volume
|
|
1731
|
-
*
|
|
1991
|
+
* Set the owned `<audio>` element's volume, clamped to `[0, 1]`. Self mode
|
|
1992
|
+
* only — a no-op in external mode where the controller owns volume.
|
|
1993
|
+
* @param {number} volume - Volume from 0 (silent) to 1 (full).
|
|
1732
1994
|
*/
|
|
1733
1995
|
setVolume(volume) {
|
|
1734
|
-
|
|
1735
|
-
|
|
1996
|
+
const v = Number(volume);
|
|
1997
|
+
if (this.audio && Number.isFinite(v)) {
|
|
1998
|
+
this.audio.volume = clamp(v);
|
|
1736
1999
|
}
|
|
1737
2000
|
}
|
|
1738
2001
|
/**
|
|
1739
|
-
* Set playback rate
|
|
1740
|
-
*
|
|
2002
|
+
* Set the owned `<audio>` element's playback rate (clamped to 0.5–2),
|
|
2003
|
+
* persist it onto `this.options.playbackRate`, and refresh the speed UI.
|
|
2004
|
+
* Self mode only — a no-op in external mode.
|
|
2005
|
+
* @param {number} rate - Desired playback rate; clamped to the 0.5–2 range.
|
|
1741
2006
|
*/
|
|
1742
2007
|
setPlaybackRate(rate) {
|
|
1743
2008
|
if (!this.audio) return;
|
|
1744
|
-
const clampedRate =
|
|
2009
|
+
const clampedRate = clamp(rate, 0.5, 2);
|
|
1745
2010
|
this.audio.playbackRate = clampedRate;
|
|
1746
2011
|
this.options.playbackRate = clampedRate;
|
|
1747
2012
|
this.updateSpeedUI();
|
|
1748
2013
|
}
|
|
1749
2014
|
/**
|
|
1750
|
-
*
|
|
2015
|
+
* Tear down the player and release all resources.
|
|
2016
|
+
*
|
|
2017
|
+
* Flags destruction (so in-flight handlers bail), dispatches
|
|
2018
|
+
* `waveformplayer:destroy`, stops playback and the animation loop, aborts
|
|
2019
|
+
* every listener registered on the instance signal, disconnects the resize
|
|
2020
|
+
* observer, removes the window-resize handler, drops the instance from the
|
|
2021
|
+
* static map and `currentlyPlaying`, resets/releases the audio element, and
|
|
2022
|
+
* empties the container.
|
|
2023
|
+
* @fires WaveformPlayer#waveformplayer:destroy
|
|
1751
2024
|
*/
|
|
1752
2025
|
destroy() {
|
|
1753
2026
|
this.isDestroying = true;
|
|
2027
|
+
this._emit("waveformplayer:destroy", { player: this, url: this.options.url });
|
|
1754
2028
|
this.pause();
|
|
1755
2029
|
this.stopSmoothUpdate();
|
|
2030
|
+
this._ac?.abort();
|
|
1756
2031
|
if (this.resizeObserver) {
|
|
1757
2032
|
this.resizeObserver.disconnect();
|
|
1758
2033
|
this.resizeObserver = null;
|
|
@@ -1825,7 +2100,7 @@
|
|
|
1825
2100
|
const result = await generateWaveform(url, samples);
|
|
1826
2101
|
return result.peaks;
|
|
1827
2102
|
} catch (error) {
|
|
1828
|
-
console.error("Failed to generate waveform:", error);
|
|
2103
|
+
console.error("[WaveformPlayer] Failed to generate waveform:", error);
|
|
1829
2104
|
throw error;
|
|
1830
2105
|
}
|
|
1831
2106
|
}
|
|
@@ -1878,8 +2153,10 @@
|
|
|
1878
2153
|
};
|
|
1879
2154
|
|
|
1880
2155
|
// src/js/index.js
|
|
2156
|
+
WaveformPlayer.utils = { formatTime, extractTitleFromUrl, escapeHtml, isSafeHref };
|
|
2157
|
+
var isBrowser = () => typeof window !== "undefined" && typeof document !== "undefined";
|
|
1881
2158
|
function autoInit() {
|
|
1882
|
-
if (
|
|
2159
|
+
if (!isBrowser()) return;
|
|
1883
2160
|
const elements = document.querySelectorAll("[data-waveform-player]");
|
|
1884
2161
|
elements.forEach((element) => {
|
|
1885
2162
|
if (element.dataset.waveformInitialized === "true") return;
|
|
@@ -1887,11 +2164,11 @@
|
|
|
1887
2164
|
new WaveformPlayer(element);
|
|
1888
2165
|
element.dataset.waveformInitialized = "true";
|
|
1889
2166
|
} catch (error) {
|
|
1890
|
-
console.error("Failed to initialize
|
|
2167
|
+
console.error("[WaveformPlayer] Failed to initialize:", error, element);
|
|
1891
2168
|
}
|
|
1892
2169
|
});
|
|
1893
2170
|
}
|
|
1894
|
-
if (
|
|
2171
|
+
if (isBrowser()) {
|
|
1895
2172
|
if (document.readyState === "loading") {
|
|
1896
2173
|
document.addEventListener("DOMContentLoaded", autoInit);
|
|
1897
2174
|
} else {
|
|
@@ -1899,7 +2176,7 @@
|
|
|
1899
2176
|
}
|
|
1900
2177
|
}
|
|
1901
2178
|
WaveformPlayer.init = autoInit;
|
|
1902
|
-
if (
|
|
2179
|
+
if (isBrowser()) {
|
|
1903
2180
|
window.WaveformPlayer = WaveformPlayer;
|
|
1904
2181
|
}
|
|
1905
2182
|
var index_default = WaveformPlayer;
|