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