@arraypress/waveform-player 1.0.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/LICENSE +21 -0
- package/README.md +187 -0
- package/dist/waveform-player.css +185 -0
- package/dist/waveform-player.esm.js +42 -0
- package/dist/waveform-player.js +1087 -0
- package/dist/waveform-player.min.js +42 -0
- package/package.json +46 -0
- package/src/audio.js +94 -0
- package/src/bpm.js +92 -0
- package/src/core.js +680 -0
- package/src/drawing.js +365 -0
- package/src/index.js +51 -0
- package/src/themes.js +94 -0
- package/src/utils.js +202 -0
|
@@ -0,0 +1,1087 @@
|
|
|
1
|
+
(() => {
|
|
2
|
+
// src/utils.js
|
|
3
|
+
function parseDataAttributes(element) {
|
|
4
|
+
const options = {};
|
|
5
|
+
if (element.dataset.url) options.url = element.dataset.url;
|
|
6
|
+
if (element.dataset.height) options.height = parseInt(element.dataset.height);
|
|
7
|
+
if (element.dataset.samples) options.samples = parseInt(element.dataset.samples);
|
|
8
|
+
if (element.dataset.waveformStyle) options.waveformStyle = element.dataset.waveformStyle;
|
|
9
|
+
if (element.dataset.barWidth) options.barWidth = parseInt(element.dataset.barWidth);
|
|
10
|
+
if (element.dataset.barSpacing) options.barSpacing = parseInt(element.dataset.barSpacing);
|
|
11
|
+
if (element.dataset.colorPreset) options.colorPreset = element.dataset.colorPreset;
|
|
12
|
+
if (element.dataset.waveformColor) options.waveformColor = element.dataset.waveformColor;
|
|
13
|
+
if (element.dataset.progressColor) options.progressColor = element.dataset.progressColor;
|
|
14
|
+
if (element.dataset.buttonColor) options.buttonColor = element.dataset.buttonColor;
|
|
15
|
+
if (element.dataset.buttonHoverColor) options.buttonHoverColor = element.dataset.buttonHoverColor;
|
|
16
|
+
if (element.dataset.textColor) options.textColor = element.dataset.textColor;
|
|
17
|
+
if (element.dataset.textSecondaryColor) options.textSecondaryColor = element.dataset.textSecondaryColor;
|
|
18
|
+
if (element.dataset.backgroundColor) options.backgroundColor = element.dataset.backgroundColor;
|
|
19
|
+
if (element.dataset.borderColor) options.borderColor = element.dataset.borderColor;
|
|
20
|
+
if (element.dataset.color) options.waveformColor = element.dataset.color;
|
|
21
|
+
if (element.dataset.theme) options.colorPreset = element.dataset.theme;
|
|
22
|
+
if (element.dataset.autoplay) options.autoplay = element.dataset.autoplay === "true";
|
|
23
|
+
if (element.dataset.showTime) options.showTime = element.dataset.showTime === "true";
|
|
24
|
+
if (element.dataset.showHoverTime) options.showHoverTime = element.dataset.showHoverTime === "true";
|
|
25
|
+
if (element.dataset.showBpm) options.showBPM = element.dataset.showBpm === "true";
|
|
26
|
+
if (element.dataset.singlePlay) options.singlePlay = element.dataset.singlePlay === "true";
|
|
27
|
+
if (element.dataset.playOnSeek) options.playOnSeek = element.dataset.playOnSeek === "true";
|
|
28
|
+
if (element.dataset.title) options.title = element.dataset.title;
|
|
29
|
+
if (element.dataset.subtitle) options.subtitle = element.dataset.subtitle;
|
|
30
|
+
if (element.dataset.waveform) options.waveform = element.dataset.waveform;
|
|
31
|
+
return options;
|
|
32
|
+
}
|
|
33
|
+
function formatTime(seconds) {
|
|
34
|
+
if (!seconds || isNaN(seconds)) return "0:00";
|
|
35
|
+
const mins = Math.floor(seconds / 60);
|
|
36
|
+
const secs = Math.floor(seconds % 60);
|
|
37
|
+
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
|
38
|
+
}
|
|
39
|
+
function generateId(url) {
|
|
40
|
+
const str = url || Math.random().toString();
|
|
41
|
+
return btoa(str.substring(0, 10)).replace(/[^a-zA-Z0-9]/g, "");
|
|
42
|
+
}
|
|
43
|
+
function extractTitleFromUrl(url) {
|
|
44
|
+
if (!url) return "Audio";
|
|
45
|
+
const parts = url.split("/");
|
|
46
|
+
const filename = parts[parts.length - 1];
|
|
47
|
+
const name = filename.split(".")[0];
|
|
48
|
+
return name.replace(/[-_]/g, " ").replace(/\b\w/g, (l) => l.toUpperCase());
|
|
49
|
+
}
|
|
50
|
+
function mergeOptions(...sources) {
|
|
51
|
+
const result = {};
|
|
52
|
+
for (const source of sources) {
|
|
53
|
+
for (const key in source) {
|
|
54
|
+
if (source[key] !== null && source[key] !== void 0) {
|
|
55
|
+
result[key] = source[key];
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
function debounce(func, wait) {
|
|
62
|
+
let timeout;
|
|
63
|
+
return function executedFunction(...args) {
|
|
64
|
+
const later = () => {
|
|
65
|
+
clearTimeout(timeout);
|
|
66
|
+
func(...args);
|
|
67
|
+
};
|
|
68
|
+
clearTimeout(timeout);
|
|
69
|
+
timeout = setTimeout(later, wait);
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
function resampleData(data, targetLength) {
|
|
73
|
+
if (data.length === targetLength) return data;
|
|
74
|
+
if (data.length === 0 || targetLength === 0) return [];
|
|
75
|
+
const result = [];
|
|
76
|
+
if (targetLength > data.length) {
|
|
77
|
+
const ratio = (data.length - 1) / (targetLength - 1);
|
|
78
|
+
for (let i = 0; i < targetLength; i++) {
|
|
79
|
+
const index = i * ratio;
|
|
80
|
+
const lower = Math.floor(index);
|
|
81
|
+
const upper = Math.ceil(index);
|
|
82
|
+
const fraction = index - lower;
|
|
83
|
+
if (upper >= data.length) {
|
|
84
|
+
result.push(data[data.length - 1]);
|
|
85
|
+
} else if (lower === upper) {
|
|
86
|
+
result.push(data[lower]);
|
|
87
|
+
} else {
|
|
88
|
+
const value = data[lower] * (1 - fraction) + data[upper] * fraction;
|
|
89
|
+
result.push(value);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
} else {
|
|
93
|
+
const bucketSize = data.length / targetLength;
|
|
94
|
+
for (let i = 0; i < targetLength; i++) {
|
|
95
|
+
const start = Math.floor(i * bucketSize);
|
|
96
|
+
const end = Math.floor((i + 1) * bucketSize);
|
|
97
|
+
let max = 0;
|
|
98
|
+
let count = 0;
|
|
99
|
+
for (let j = start; j <= end && j < data.length; j++) {
|
|
100
|
+
if (data[j] > max) {
|
|
101
|
+
max = data[j];
|
|
102
|
+
}
|
|
103
|
+
count++;
|
|
104
|
+
}
|
|
105
|
+
if (count === 0) {
|
|
106
|
+
const nearestIndex = Math.min(Math.round(i * bucketSize), data.length - 1);
|
|
107
|
+
max = data[nearestIndex];
|
|
108
|
+
}
|
|
109
|
+
result.push(max);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return result;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// src/drawing.js
|
|
116
|
+
function drawBars(ctx, canvas, peaks, progress, options) {
|
|
117
|
+
const dpr = window.devicePixelRatio || 1;
|
|
118
|
+
const barWidth = options.barWidth * dpr;
|
|
119
|
+
const barSpacing = options.barSpacing * dpr;
|
|
120
|
+
const barCount = Math.floor(canvas.width / (barWidth + barSpacing));
|
|
121
|
+
const resampledPeaks = resampleData(peaks, barCount);
|
|
122
|
+
const height = canvas.height;
|
|
123
|
+
const progressWidth = progress * canvas.width;
|
|
124
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
125
|
+
for (let i = 0; i < resampledPeaks.length; i++) {
|
|
126
|
+
const x = i * (barWidth + barSpacing);
|
|
127
|
+
if (x + barWidth > canvas.width) break;
|
|
128
|
+
const peakHeight = resampledPeaks[i] * height * 0.9;
|
|
129
|
+
const y = height - peakHeight;
|
|
130
|
+
ctx.fillStyle = options.color;
|
|
131
|
+
ctx.fillRect(x, y, barWidth, peakHeight);
|
|
132
|
+
}
|
|
133
|
+
ctx.save();
|
|
134
|
+
ctx.beginPath();
|
|
135
|
+
ctx.rect(0, 0, progressWidth, height);
|
|
136
|
+
ctx.clip();
|
|
137
|
+
for (let i = 0; i < resampledPeaks.length; i++) {
|
|
138
|
+
const x = i * (barWidth + barSpacing);
|
|
139
|
+
if (x > progressWidth) break;
|
|
140
|
+
const peakHeight = resampledPeaks[i] * height * 0.9;
|
|
141
|
+
const y = height - peakHeight;
|
|
142
|
+
ctx.fillStyle = options.progressColor;
|
|
143
|
+
ctx.fillRect(x, y, barWidth, peakHeight);
|
|
144
|
+
}
|
|
145
|
+
ctx.restore();
|
|
146
|
+
}
|
|
147
|
+
function drawMirror(ctx, canvas, peaks, progress, options) {
|
|
148
|
+
const dpr = window.devicePixelRatio || 1;
|
|
149
|
+
const barWidth = options.barWidth * dpr;
|
|
150
|
+
const barSpacing = options.barSpacing * dpr;
|
|
151
|
+
const barCount = Math.floor(canvas.width / (barWidth + barSpacing));
|
|
152
|
+
const resampledPeaks = resampleData(peaks, barCount);
|
|
153
|
+
const height = canvas.height;
|
|
154
|
+
const centerY = height / 2;
|
|
155
|
+
const progressWidth = progress * canvas.width;
|
|
156
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
157
|
+
for (let i = 0; i < resampledPeaks.length; i++) {
|
|
158
|
+
const x = i * (barWidth + barSpacing);
|
|
159
|
+
if (x + barWidth > canvas.width) break;
|
|
160
|
+
const peakHeight = resampledPeaks[i] * height * 0.45;
|
|
161
|
+
ctx.fillStyle = options.color;
|
|
162
|
+
ctx.fillRect(x, centerY - peakHeight, barWidth, peakHeight);
|
|
163
|
+
ctx.fillRect(x, centerY, barWidth, peakHeight);
|
|
164
|
+
}
|
|
165
|
+
ctx.save();
|
|
166
|
+
ctx.beginPath();
|
|
167
|
+
ctx.rect(0, 0, progressWidth, height);
|
|
168
|
+
ctx.clip();
|
|
169
|
+
for (let i = 0; i < resampledPeaks.length; i++) {
|
|
170
|
+
const x = i * (barWidth + barSpacing);
|
|
171
|
+
if (x > progressWidth) break;
|
|
172
|
+
const peakHeight = resampledPeaks[i] * height * 0.45;
|
|
173
|
+
ctx.fillStyle = options.progressColor;
|
|
174
|
+
ctx.fillRect(x, centerY - peakHeight, barWidth, peakHeight);
|
|
175
|
+
ctx.fillRect(x, centerY, barWidth, peakHeight);
|
|
176
|
+
}
|
|
177
|
+
ctx.restore();
|
|
178
|
+
}
|
|
179
|
+
function drawLine(ctx, canvas, peaks, progress, options) {
|
|
180
|
+
const width = canvas.width;
|
|
181
|
+
const height = canvas.height;
|
|
182
|
+
const centerY = height / 2;
|
|
183
|
+
const amplitude = height * 0.35;
|
|
184
|
+
ctx.clearRect(0, 0, width, height);
|
|
185
|
+
const drawCurve = (color, lineWidth, endProgress = 1, addGlow = false) => {
|
|
186
|
+
if (addGlow) {
|
|
187
|
+
ctx.shadowBlur = 12;
|
|
188
|
+
ctx.shadowColor = color;
|
|
189
|
+
}
|
|
190
|
+
ctx.strokeStyle = color;
|
|
191
|
+
ctx.lineWidth = lineWidth;
|
|
192
|
+
ctx.lineCap = "round";
|
|
193
|
+
ctx.lineJoin = "round";
|
|
194
|
+
ctx.beginPath();
|
|
195
|
+
ctx.moveTo(0, centerY);
|
|
196
|
+
const points = [];
|
|
197
|
+
const samples = Math.floor(peaks.length * endProgress);
|
|
198
|
+
for (let i = 0; i < samples; i++) {
|
|
199
|
+
const x = i / (peaks.length - 1) * width;
|
|
200
|
+
const peakValue = peaks[i];
|
|
201
|
+
const waveOffset = Math.sin(i * 0.1) * peakValue;
|
|
202
|
+
const y = centerY + waveOffset * amplitude;
|
|
203
|
+
points.push({ x, y });
|
|
204
|
+
}
|
|
205
|
+
for (let i = 0; i < points.length - 1; i++) {
|
|
206
|
+
const cp1x = points[i].x + (points[i + 1].x - points[i].x) * 0.5;
|
|
207
|
+
const cp1y = points[i].y;
|
|
208
|
+
const cp2x = points[i + 1].x - (points[i + 1].x - points[i].x) * 0.5;
|
|
209
|
+
const cp2y = points[i + 1].y;
|
|
210
|
+
ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, points[i + 1].x, points[i + 1].y);
|
|
211
|
+
}
|
|
212
|
+
ctx.stroke();
|
|
213
|
+
if (addGlow) {
|
|
214
|
+
ctx.shadowBlur = 0;
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
ctx.strokeStyle = "rgba(255, 255, 255, 0.03)";
|
|
218
|
+
ctx.lineWidth = 0.5;
|
|
219
|
+
ctx.beginPath();
|
|
220
|
+
ctx.moveTo(0, centerY);
|
|
221
|
+
ctx.lineTo(width, centerY);
|
|
222
|
+
ctx.stroke();
|
|
223
|
+
for (let i = 0; i <= 10; i++) {
|
|
224
|
+
const x = width / 10 * i;
|
|
225
|
+
ctx.beginPath();
|
|
226
|
+
ctx.moveTo(x, 0);
|
|
227
|
+
ctx.lineTo(x, height);
|
|
228
|
+
ctx.stroke();
|
|
229
|
+
}
|
|
230
|
+
drawCurve(options.color, 2, 1, false);
|
|
231
|
+
if (progress > 0) {
|
|
232
|
+
drawCurve(options.progressColor, 3, progress, true);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
function drawBlocks(ctx, canvas, peaks, progress, options) {
|
|
236
|
+
const dpr = window.devicePixelRatio || 1;
|
|
237
|
+
const barWidth = (options.barWidth || 3) * dpr;
|
|
238
|
+
const barSpacing = (options.barSpacing || 1) * dpr;
|
|
239
|
+
const barCount = Math.floor(canvas.width / (barWidth + barSpacing));
|
|
240
|
+
const resampledPeaks = resampleData(peaks, barCount);
|
|
241
|
+
const height = canvas.height;
|
|
242
|
+
const blockSize = 4 * dpr;
|
|
243
|
+
const blockGap = 2 * dpr;
|
|
244
|
+
const progressWidth = progress * canvas.width;
|
|
245
|
+
const centerY = height / 2;
|
|
246
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
247
|
+
for (let i = 0; i < resampledPeaks.length; i++) {
|
|
248
|
+
const x = i * (barWidth + barSpacing);
|
|
249
|
+
if (x + barWidth > canvas.width) break;
|
|
250
|
+
const peakHeight = resampledPeaks[i] * height * 0.9;
|
|
251
|
+
const blockCount = Math.floor(peakHeight / (blockSize + blockGap));
|
|
252
|
+
ctx.fillStyle = x < progressWidth ? options.progressColor : options.color;
|
|
253
|
+
for (let j = 0; j < blockCount; j++) {
|
|
254
|
+
const blockOffset = j * (blockSize + blockGap);
|
|
255
|
+
ctx.fillRect(x, centerY - blockOffset - blockSize, barWidth, blockSize);
|
|
256
|
+
if (j > 0) {
|
|
257
|
+
ctx.fillRect(x, centerY + blockOffset, barWidth, blockSize);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
function drawDots(ctx, canvas, peaks, progress, options) {
|
|
263
|
+
const dpr = window.devicePixelRatio || 1;
|
|
264
|
+
const barWidth = (options.barWidth || 2) * dpr;
|
|
265
|
+
const barSpacing = (options.barSpacing || 3) * dpr;
|
|
266
|
+
const barCount = Math.floor(canvas.width / (barWidth + barSpacing));
|
|
267
|
+
const resampledPeaks = resampleData(peaks, barCount);
|
|
268
|
+
const height = canvas.height;
|
|
269
|
+
const dotRadius = Math.max(1.5 * dpr, barWidth / 2);
|
|
270
|
+
const progressWidth = progress * canvas.width;
|
|
271
|
+
const centerY = height / 2;
|
|
272
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
273
|
+
for (let i = 0; i < resampledPeaks.length; i++) {
|
|
274
|
+
const x = i * (barWidth + barSpacing) + barWidth / 2;
|
|
275
|
+
if (x > canvas.width) break;
|
|
276
|
+
const peakHeight = resampledPeaks[i] * height * 0.9;
|
|
277
|
+
ctx.fillStyle = x < progressWidth ? options.progressColor : options.color;
|
|
278
|
+
ctx.beginPath();
|
|
279
|
+
ctx.arc(x, centerY - peakHeight / 2, dotRadius, 0, Math.PI * 2);
|
|
280
|
+
ctx.fill();
|
|
281
|
+
ctx.beginPath();
|
|
282
|
+
ctx.arc(x, centerY + peakHeight / 2, dotRadius, 0, Math.PI * 2);
|
|
283
|
+
ctx.fill();
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
function drawSeekbar(ctx, canvas, peaks, progress, options) {
|
|
287
|
+
const width = canvas.width;
|
|
288
|
+
const height = canvas.height;
|
|
289
|
+
const centerY = height / 2;
|
|
290
|
+
const barHeight = 4;
|
|
291
|
+
const borderRadius = barHeight / 2;
|
|
292
|
+
ctx.clearRect(0, 0, width, height);
|
|
293
|
+
ctx.fillStyle = options.color || "rgba(255, 255, 255, 0.2)";
|
|
294
|
+
ctx.beginPath();
|
|
295
|
+
ctx.moveTo(borderRadius, centerY - barHeight / 2);
|
|
296
|
+
ctx.lineTo(width - borderRadius, centerY - barHeight / 2);
|
|
297
|
+
ctx.arc(width - borderRadius, centerY, barHeight / 2, -Math.PI / 2, Math.PI / 2);
|
|
298
|
+
ctx.lineTo(borderRadius, centerY + barHeight / 2);
|
|
299
|
+
ctx.arc(borderRadius, centerY, barHeight / 2, Math.PI / 2, -Math.PI / 2);
|
|
300
|
+
ctx.closePath();
|
|
301
|
+
ctx.fill();
|
|
302
|
+
if (progress > 0) {
|
|
303
|
+
const progressWidth = Math.max(borderRadius * 2, progress * width);
|
|
304
|
+
ctx.shadowBlur = 8;
|
|
305
|
+
ctx.shadowColor = options.progressColor;
|
|
306
|
+
ctx.fillStyle = options.progressColor || "rgba(255, 255, 255, 0.9)";
|
|
307
|
+
ctx.beginPath();
|
|
308
|
+
ctx.moveTo(borderRadius, centerY - barHeight / 2);
|
|
309
|
+
ctx.lineTo(progressWidth - borderRadius, centerY - barHeight / 2);
|
|
310
|
+
ctx.arc(progressWidth - borderRadius, centerY, barHeight / 2, -Math.PI / 2, Math.PI / 2);
|
|
311
|
+
ctx.lineTo(borderRadius, centerY + barHeight / 2);
|
|
312
|
+
ctx.arc(borderRadius, centerY, barHeight / 2, Math.PI / 2, -Math.PI / 2);
|
|
313
|
+
ctx.closePath();
|
|
314
|
+
ctx.fill();
|
|
315
|
+
ctx.shadowBlur = 0;
|
|
316
|
+
const handleRadius = 8;
|
|
317
|
+
const handleX = progressWidth;
|
|
318
|
+
ctx.shadowBlur = 4;
|
|
319
|
+
ctx.shadowColor = "rgba(0, 0, 0, 0.3)";
|
|
320
|
+
ctx.shadowOffsetY = 2;
|
|
321
|
+
ctx.fillStyle = "#ffffff";
|
|
322
|
+
ctx.beginPath();
|
|
323
|
+
ctx.arc(handleX, centerY, handleRadius, 0, Math.PI * 2);
|
|
324
|
+
ctx.fill();
|
|
325
|
+
ctx.shadowBlur = 0;
|
|
326
|
+
ctx.shadowOffsetY = 0;
|
|
327
|
+
ctx.fillStyle = options.progressColor || "rgba(255, 255, 255, 0.9)";
|
|
328
|
+
ctx.beginPath();
|
|
329
|
+
ctx.arc(handleX, centerY, handleRadius * 0.4, 0, Math.PI * 2);
|
|
330
|
+
ctx.fill();
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
var DRAWING_STYLES = {
|
|
334
|
+
"bars": drawBars,
|
|
335
|
+
// Classic vertical bars
|
|
336
|
+
"mirror": drawMirror,
|
|
337
|
+
// SoundCloud-style symmetrical
|
|
338
|
+
"line": drawLine,
|
|
339
|
+
// Smooth oscilloscope wave
|
|
340
|
+
"blocks": drawBlocks,
|
|
341
|
+
// LED meter segmented
|
|
342
|
+
"dots": drawDots,
|
|
343
|
+
// Circular points
|
|
344
|
+
"seekbar": drawSeekbar
|
|
345
|
+
// Simple progress bar (no waveform)
|
|
346
|
+
};
|
|
347
|
+
function draw(ctx, canvas, peaks, progress, options) {
|
|
348
|
+
const drawFunc = DRAWING_STYLES[options.waveformStyle] || drawBars;
|
|
349
|
+
drawFunc(ctx, canvas, peaks, progress, options);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// src/bpm.js
|
|
353
|
+
function detectBPM(buffer) {
|
|
354
|
+
try {
|
|
355
|
+
const channelData = buffer.getChannelData(0);
|
|
356
|
+
const sampleRate = buffer.sampleRate;
|
|
357
|
+
const onsets = detectOnsets(channelData, sampleRate);
|
|
358
|
+
if (onsets.length < 2) return 120;
|
|
359
|
+
const intervals = [];
|
|
360
|
+
for (let i = 1; i < onsets.length; i++) {
|
|
361
|
+
intervals.push((onsets[i] - onsets[i - 1]) / sampleRate);
|
|
362
|
+
}
|
|
363
|
+
const tempoGroups = {};
|
|
364
|
+
intervals.forEach((interval) => {
|
|
365
|
+
const tempo = 60 / interval;
|
|
366
|
+
const bucket = Math.round(tempo / 3) * 3;
|
|
367
|
+
if (bucket > 60 && bucket < 200) {
|
|
368
|
+
tempoGroups[bucket] = (tempoGroups[bucket] || 0) + 1;
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
let maxCount = 0;
|
|
372
|
+
let detectedBPM = 120;
|
|
373
|
+
for (const [tempo, count] of Object.entries(tempoGroups)) {
|
|
374
|
+
if (count > maxCount) {
|
|
375
|
+
maxCount = count;
|
|
376
|
+
detectedBPM = parseInt(tempo);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
if (detectedBPM < 70 && tempoGroups[detectedBPM * 2]) {
|
|
380
|
+
detectedBPM *= 2;
|
|
381
|
+
} else if (detectedBPM > 160 && tempoGroups[Math.round(detectedBPM / 2)]) {
|
|
382
|
+
detectedBPM = Math.round(detectedBPM / 2);
|
|
383
|
+
}
|
|
384
|
+
return detectedBPM - 1;
|
|
385
|
+
} catch (e) {
|
|
386
|
+
console.warn("BPM detection failed:", e);
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
function detectOnsets(channelData, sampleRate) {
|
|
391
|
+
const windowSize = 2048;
|
|
392
|
+
const hopSize = windowSize / 2;
|
|
393
|
+
const onsets = [];
|
|
394
|
+
let previousEnergy = 0;
|
|
395
|
+
for (let i = 0; i < channelData.length - windowSize; i += hopSize) {
|
|
396
|
+
let energy = 0;
|
|
397
|
+
for (let j = i; j < i + windowSize; j++) {
|
|
398
|
+
energy += channelData[j] * channelData[j];
|
|
399
|
+
}
|
|
400
|
+
energy = energy / windowSize;
|
|
401
|
+
const energyDiff = energy - previousEnergy;
|
|
402
|
+
const threshold = previousEnergy * 1.8 + 0.01;
|
|
403
|
+
if (energyDiff > threshold && energy > 0.01) {
|
|
404
|
+
const lastOnset = onsets[onsets.length - 1] || 0;
|
|
405
|
+
const minDistance = sampleRate * 0.15;
|
|
406
|
+
if (i - lastOnset > minDistance) {
|
|
407
|
+
onsets.push(i);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
previousEnergy = energy * 0.8 + previousEnergy * 0.2;
|
|
411
|
+
}
|
|
412
|
+
return onsets;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// src/audio.js
|
|
416
|
+
function extractPeaks(buffer, samples = 200) {
|
|
417
|
+
const sampleSize = buffer.length / samples;
|
|
418
|
+
const sampleStep = ~~(sampleSize / 10) || 1;
|
|
419
|
+
const channels = buffer.numberOfChannels;
|
|
420
|
+
const peaks = [];
|
|
421
|
+
for (let c = 0; c < channels; c++) {
|
|
422
|
+
const chan = buffer.getChannelData(c);
|
|
423
|
+
for (let i = 0; i < samples; i++) {
|
|
424
|
+
const start = ~~(i * sampleSize);
|
|
425
|
+
const end = ~~(start + sampleSize);
|
|
426
|
+
let min = 0;
|
|
427
|
+
let max = 0;
|
|
428
|
+
for (let j = start; j < end; j += sampleStep) {
|
|
429
|
+
const value = chan[j];
|
|
430
|
+
if (value > max) max = value;
|
|
431
|
+
if (value < min) min = value;
|
|
432
|
+
}
|
|
433
|
+
const peak = Math.max(Math.abs(max), Math.abs(min));
|
|
434
|
+
if (c === 0 || peak > peaks[i]) {
|
|
435
|
+
peaks[i] = peak;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
const maxPeak = Math.max(...peaks);
|
|
440
|
+
return maxPeak > 0 ? peaks.map((peak) => peak / maxPeak) : peaks;
|
|
441
|
+
}
|
|
442
|
+
async function generateWaveform(url, samples = 200, includeBPM = false) {
|
|
443
|
+
const response = await fetch(url);
|
|
444
|
+
if (!response.ok) {
|
|
445
|
+
throw new Error(`HTTP error! status: ${response.status}`);
|
|
446
|
+
}
|
|
447
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
448
|
+
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
|
|
449
|
+
const audioContext = new AudioContextClass();
|
|
450
|
+
try {
|
|
451
|
+
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
|
|
452
|
+
const peaks = extractPeaks(audioBuffer, samples);
|
|
453
|
+
const result = { peaks };
|
|
454
|
+
if (includeBPM) {
|
|
455
|
+
result.bpm = detectBPM(audioBuffer);
|
|
456
|
+
}
|
|
457
|
+
return result;
|
|
458
|
+
} finally {
|
|
459
|
+
await audioContext.close();
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
function generatePlaceholderWaveform(samples = 200) {
|
|
463
|
+
const data = [];
|
|
464
|
+
for (let i = 0; i < samples; i++) {
|
|
465
|
+
const base = Math.random() * 0.5 + 0.3;
|
|
466
|
+
const variation = Math.sin(i / samples * Math.PI * 4) * 0.2;
|
|
467
|
+
data.push(Math.max(0.1, Math.min(1, base + variation)));
|
|
468
|
+
}
|
|
469
|
+
return data;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// src/themes.js
|
|
473
|
+
var DEFAULT_OPTIONS = {
|
|
474
|
+
// Core settings
|
|
475
|
+
url: "",
|
|
476
|
+
height: 60,
|
|
477
|
+
samples: 200,
|
|
478
|
+
// Default waveform style
|
|
479
|
+
waveformStyle: "mirror",
|
|
480
|
+
barWidth: 2,
|
|
481
|
+
barSpacing: 0,
|
|
482
|
+
// Color preset (dark/light or null for custom)
|
|
483
|
+
colorPreset: "dark",
|
|
484
|
+
// Individual color overrides (null means use preset)
|
|
485
|
+
waveformColor: null,
|
|
486
|
+
progressColor: null,
|
|
487
|
+
buttonColor: null,
|
|
488
|
+
buttonHoverColor: null,
|
|
489
|
+
textColor: null,
|
|
490
|
+
textSecondaryColor: null,
|
|
491
|
+
backgroundColor: null,
|
|
492
|
+
borderColor: null,
|
|
493
|
+
// Features
|
|
494
|
+
autoplay: false,
|
|
495
|
+
showTime: true,
|
|
496
|
+
showHoverTime: false,
|
|
497
|
+
showBPM: false,
|
|
498
|
+
singlePlay: true,
|
|
499
|
+
playOnSeek: true,
|
|
500
|
+
// Content
|
|
501
|
+
title: null,
|
|
502
|
+
subtitle: null,
|
|
503
|
+
// Icons (SVG)
|
|
504
|
+
playIcon: '<svg viewBox="0 0 24 24" width="16" height="16"><path d="M8 5v14l11-7z"/></svg>',
|
|
505
|
+
pauseIcon: '<svg viewBox="0 0 24 24" width="16" height="16"><path d="M6 4h4v16H6zM14 4h4v16h-4z"/></svg>',
|
|
506
|
+
// Callbacks
|
|
507
|
+
onLoad: null,
|
|
508
|
+
onPlay: null,
|
|
509
|
+
onPause: null,
|
|
510
|
+
onEnd: null,
|
|
511
|
+
onError: null,
|
|
512
|
+
onTimeUpdate: null
|
|
513
|
+
};
|
|
514
|
+
var STYLE_DEFAULTS = {
|
|
515
|
+
bars: { barWidth: 3, barSpacing: 1 },
|
|
516
|
+
mirror: { barWidth: 2, barSpacing: 0 },
|
|
517
|
+
line: { barWidth: 2, barSpacing: 0 },
|
|
518
|
+
blocks: { barWidth: 4, barSpacing: 2 },
|
|
519
|
+
dots: { barWidth: 3, barSpacing: 3 },
|
|
520
|
+
seekbar: { barWidth: 1, barSpacing: 0 }
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
// src/core.js
|
|
524
|
+
var WaveformPlayer = class _WaveformPlayer {
|
|
525
|
+
/** @type {Map<string, WaveformPlayer>} */
|
|
526
|
+
static instances = /* @__PURE__ */ new Map();
|
|
527
|
+
/** @type {WaveformPlayer|null} */
|
|
528
|
+
static currentlyPlaying = null;
|
|
529
|
+
/**
|
|
530
|
+
* Create a new WaveformPlayer instance
|
|
531
|
+
* @param {string|HTMLElement} container - Container element or selector
|
|
532
|
+
* @param {Object} options - Player options
|
|
533
|
+
*/
|
|
534
|
+
constructor(container, options = {}) {
|
|
535
|
+
this.container = typeof container === "string" ? document.querySelector(container) : container;
|
|
536
|
+
if (!this.container) {
|
|
537
|
+
throw new Error("WaveformPlayer: Container element not found");
|
|
538
|
+
}
|
|
539
|
+
const dataOptions = parseDataAttributes(this.container);
|
|
540
|
+
this.options = mergeOptions(DEFAULT_OPTIONS, dataOptions, options);
|
|
541
|
+
const styleDefaults = STYLE_DEFAULTS[this.options.waveformStyle];
|
|
542
|
+
if (styleDefaults) {
|
|
543
|
+
if (dataOptions.barWidth === void 0 && options.barWidth === void 0) {
|
|
544
|
+
this.options.barWidth = styleDefaults.barWidth;
|
|
545
|
+
}
|
|
546
|
+
if (dataOptions.barSpacing === void 0 && options.barSpacing === void 0) {
|
|
547
|
+
this.options.barSpacing = styleDefaults.barSpacing;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
this.options.waveformColor = this.options.waveformColor || "rgba(255, 255, 255, 0.3)";
|
|
551
|
+
this.options.progressColor = this.options.progressColor || "rgba(255, 255, 255, 0.9)";
|
|
552
|
+
this.options.buttonColor = this.options.buttonColor || "rgba(255, 255, 255, 0.9)";
|
|
553
|
+
this.options.textColor = this.options.textColor || "#ffffff";
|
|
554
|
+
this.options.textSecondaryColor = this.options.textSecondaryColor || "rgba(255, 255, 255, 0.6)";
|
|
555
|
+
this.audio = null;
|
|
556
|
+
this.canvas = null;
|
|
557
|
+
this.ctx = null;
|
|
558
|
+
this.waveformData = [];
|
|
559
|
+
this.progress = 0;
|
|
560
|
+
this.isPlaying = false;
|
|
561
|
+
this.isLoading = false;
|
|
562
|
+
this.hasError = false;
|
|
563
|
+
this.updateTimer = null;
|
|
564
|
+
this.resizeObserver = null;
|
|
565
|
+
this.id = this.container.id || generateId(this.options.url);
|
|
566
|
+
_WaveformPlayer.instances.set(this.id, this);
|
|
567
|
+
this.init();
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* Initialize the player
|
|
571
|
+
* @private
|
|
572
|
+
*/
|
|
573
|
+
init() {
|
|
574
|
+
this.createDOM();
|
|
575
|
+
this.createAudio();
|
|
576
|
+
this.bindEvents();
|
|
577
|
+
this.setupResizeObserver();
|
|
578
|
+
requestAnimationFrame(() => {
|
|
579
|
+
this.resizeCanvas();
|
|
580
|
+
if (this.options.url) {
|
|
581
|
+
this.load(this.options.url).then(() => {
|
|
582
|
+
if (this.options.autoplay) {
|
|
583
|
+
this.play();
|
|
584
|
+
}
|
|
585
|
+
}).catch((error) => {
|
|
586
|
+
console.error("Failed to load audio:", error);
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* Create DOM elements
|
|
593
|
+
* @private
|
|
594
|
+
*/
|
|
595
|
+
createDOM() {
|
|
596
|
+
this.container.innerHTML = "";
|
|
597
|
+
this.container.className = "waveform-player";
|
|
598
|
+
this.container.innerHTML = `
|
|
599
|
+
<div class="waveform-player-inner">
|
|
600
|
+
<div class="waveform-body">
|
|
601
|
+
<div class="waveform-track">
|
|
602
|
+
<button class="waveform-btn" aria-label="Play/Pause" style="
|
|
603
|
+
border-color: ${this.options.buttonColor};
|
|
604
|
+
color: ${this.options.buttonColor};
|
|
605
|
+
">
|
|
606
|
+
<span class="waveform-icon-play">${this.options.playIcon}</span>
|
|
607
|
+
<span class="waveform-icon-pause" style="display:none;">${this.options.pauseIcon}</span>
|
|
608
|
+
</button>
|
|
609
|
+
|
|
610
|
+
<div class="waveform-container">
|
|
611
|
+
<canvas></canvas>
|
|
612
|
+
<div class="waveform-loading" style="display:none;"></div>
|
|
613
|
+
<div class="waveform-error" style="display:none;">
|
|
614
|
+
<span class="waveform-error-text">Unable to load audio</span>
|
|
615
|
+
</div>
|
|
616
|
+
</div>
|
|
617
|
+
</div>
|
|
618
|
+
|
|
619
|
+
<div class="waveform-info">
|
|
620
|
+
<div class="waveform-text">
|
|
621
|
+
<span class="waveform-title" style="color: ${this.options.textColor};"></span>
|
|
622
|
+
${this.options.subtitle ? `<span class="waveform-subtitle" style="color: ${this.options.textSecondaryColor};">${this.options.subtitle}</span>` : ""}
|
|
623
|
+
</div>
|
|
624
|
+
<div style="display: flex; align-items: center; gap: 1rem;">
|
|
625
|
+
${this.options.showBPM ? `
|
|
626
|
+
<span class="waveform-bpm" style="color: ${this.options.textSecondaryColor}; display: none;">
|
|
627
|
+
<span class="bpm-value">--</span> BPM
|
|
628
|
+
</span>
|
|
629
|
+
` : ""}
|
|
630
|
+
${this.options.showTime ? `
|
|
631
|
+
<span class="waveform-time" style="color: ${this.options.textSecondaryColor};">
|
|
632
|
+
<span class="time-current">0:00</span> / <span class="time-total">0:00</span>
|
|
633
|
+
</span>
|
|
634
|
+
` : ""}
|
|
635
|
+
</div>
|
|
636
|
+
</div>
|
|
637
|
+
</div>
|
|
638
|
+
</div>
|
|
639
|
+
`;
|
|
640
|
+
this.playBtn = this.container.querySelector(".waveform-btn");
|
|
641
|
+
this.canvas = this.container.querySelector("canvas");
|
|
642
|
+
this.ctx = this.canvas.getContext("2d");
|
|
643
|
+
this.titleEl = this.container.querySelector(".waveform-title");
|
|
644
|
+
this.subtitleEl = this.container.querySelector(".waveform-subtitle");
|
|
645
|
+
this.currentTimeEl = this.container.querySelector(".time-current");
|
|
646
|
+
this.totalTimeEl = this.container.querySelector(".time-total");
|
|
647
|
+
this.bpmEl = this.container.querySelector(".waveform-bpm");
|
|
648
|
+
this.bpmValueEl = this.container.querySelector(".bpm-value");
|
|
649
|
+
this.loadingEl = this.container.querySelector(".waveform-loading");
|
|
650
|
+
this.errorEl = this.container.querySelector(".waveform-error");
|
|
651
|
+
this.resizeCanvas();
|
|
652
|
+
}
|
|
653
|
+
/**
|
|
654
|
+
* Create audio element
|
|
655
|
+
* @private
|
|
656
|
+
*/
|
|
657
|
+
createAudio() {
|
|
658
|
+
this.audio = new Audio();
|
|
659
|
+
this.audio.preload = "metadata";
|
|
660
|
+
this.audio.crossOrigin = "anonymous";
|
|
661
|
+
}
|
|
662
|
+
/**
|
|
663
|
+
* Bind event listeners
|
|
664
|
+
* @private
|
|
665
|
+
*/
|
|
666
|
+
bindEvents() {
|
|
667
|
+
this.playBtn.addEventListener("click", () => this.togglePlay());
|
|
668
|
+
this.audio.addEventListener("loadstart", () => this.setLoading(true));
|
|
669
|
+
this.audio.addEventListener("loadedmetadata", () => this.onMetadataLoaded());
|
|
670
|
+
this.audio.addEventListener("canplay", () => this.setLoading(false));
|
|
671
|
+
this.audio.addEventListener("play", () => this.onPlay());
|
|
672
|
+
this.audio.addEventListener("pause", () => this.onPause());
|
|
673
|
+
this.audio.addEventListener("ended", () => this.onEnded());
|
|
674
|
+
this.audio.addEventListener("error", (e) => this.onError(e));
|
|
675
|
+
this.canvas.addEventListener("click", (e) => this.handleCanvasClick(e));
|
|
676
|
+
window.addEventListener("resize", debounce(() => this.resizeCanvas(), 100));
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* Setup resize observer
|
|
680
|
+
* @private
|
|
681
|
+
*/
|
|
682
|
+
setupResizeObserver() {
|
|
683
|
+
if ("ResizeObserver" in window) {
|
|
684
|
+
this.resizeObserver = new ResizeObserver(() => {
|
|
685
|
+
this.resizeCanvas();
|
|
686
|
+
});
|
|
687
|
+
if (this.canvas?.parentElement) {
|
|
688
|
+
this.resizeObserver.observe(this.canvas.parentElement);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
/**
|
|
693
|
+
* Load audio file
|
|
694
|
+
* @param {string} url - Audio URL
|
|
695
|
+
* @returns {Promise<void>}
|
|
696
|
+
*/
|
|
697
|
+
async load(url) {
|
|
698
|
+
try {
|
|
699
|
+
this.setLoading(true);
|
|
700
|
+
this.progress = 0;
|
|
701
|
+
this.hasError = false;
|
|
702
|
+
this.audio.src = url;
|
|
703
|
+
await new Promise((resolve, reject) => {
|
|
704
|
+
const metadataHandler = () => {
|
|
705
|
+
this.audio.removeEventListener("loadedmetadata", metadataHandler);
|
|
706
|
+
this.audio.removeEventListener("error", errorHandler);
|
|
707
|
+
resolve();
|
|
708
|
+
};
|
|
709
|
+
const errorHandler = (e) => {
|
|
710
|
+
this.audio.removeEventListener("loadedmetadata", metadataHandler);
|
|
711
|
+
this.audio.removeEventListener("error", errorHandler);
|
|
712
|
+
reject(e);
|
|
713
|
+
};
|
|
714
|
+
this.audio.addEventListener("loadedmetadata", metadataHandler);
|
|
715
|
+
this.audio.addEventListener("error", errorHandler);
|
|
716
|
+
});
|
|
717
|
+
const title = this.options.title || extractTitleFromUrl(url);
|
|
718
|
+
if (this.titleEl) {
|
|
719
|
+
this.titleEl.textContent = title;
|
|
720
|
+
}
|
|
721
|
+
if (this.options.waveform) {
|
|
722
|
+
this.setWaveformData(this.options.waveform);
|
|
723
|
+
} else {
|
|
724
|
+
try {
|
|
725
|
+
const result = await generateWaveform(url, this.options.samples, this.options.showBPM);
|
|
726
|
+
this.waveformData = result.peaks;
|
|
727
|
+
if (result.bpm) {
|
|
728
|
+
this.detectedBPM = result.bpm;
|
|
729
|
+
this.updateBPMDisplay();
|
|
730
|
+
}
|
|
731
|
+
} catch (error) {
|
|
732
|
+
console.warn("Using placeholder waveform:", error);
|
|
733
|
+
this.waveformData = generatePlaceholderWaveform(this.options.samples);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
this.drawWaveform();
|
|
737
|
+
if (this.options.onLoad) {
|
|
738
|
+
this.options.onLoad(this);
|
|
739
|
+
}
|
|
740
|
+
} catch (error) {
|
|
741
|
+
console.error("Failed to load audio:", error);
|
|
742
|
+
this.onError(error);
|
|
743
|
+
} finally {
|
|
744
|
+
this.setLoading(false);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* Set waveform data
|
|
749
|
+
* @private
|
|
750
|
+
*/
|
|
751
|
+
setWaveformData(data) {
|
|
752
|
+
if (typeof data === "string") {
|
|
753
|
+
try {
|
|
754
|
+
const parsed = JSON.parse(data);
|
|
755
|
+
this.waveformData = Array.isArray(parsed) ? parsed : [];
|
|
756
|
+
} catch {
|
|
757
|
+
this.waveformData = data.split(",").map(Number);
|
|
758
|
+
}
|
|
759
|
+
} else {
|
|
760
|
+
this.waveformData = Array.isArray(data) ? data : [];
|
|
761
|
+
}
|
|
762
|
+
this.drawWaveform();
|
|
763
|
+
}
|
|
764
|
+
/**
|
|
765
|
+
* Draw waveform
|
|
766
|
+
* @private
|
|
767
|
+
*/
|
|
768
|
+
drawWaveform() {
|
|
769
|
+
if (!this.ctx || this.waveformData.length === 0) return;
|
|
770
|
+
draw(this.ctx, this.canvas, this.waveformData, this.progress, {
|
|
771
|
+
...this.options,
|
|
772
|
+
waveformStyle: this.options.waveformStyle || "bars",
|
|
773
|
+
color: this.options.waveformColor,
|
|
774
|
+
progressColor: this.options.progressColor
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
/**
|
|
778
|
+
* Resize canvas
|
|
779
|
+
* @private
|
|
780
|
+
*/
|
|
781
|
+
resizeCanvas() {
|
|
782
|
+
const dpr = window.devicePixelRatio || 1;
|
|
783
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
784
|
+
this.canvas.width = rect.width * dpr;
|
|
785
|
+
this.canvas.height = this.options.height * dpr;
|
|
786
|
+
this.canvas.style.height = this.options.height + "px";
|
|
787
|
+
this.canvas.parentElement.style.height = this.options.height + "px";
|
|
788
|
+
this.drawWaveform();
|
|
789
|
+
}
|
|
790
|
+
/**
|
|
791
|
+
* Handle canvas click
|
|
792
|
+
* @private
|
|
793
|
+
*/
|
|
794
|
+
handleCanvasClick(event) {
|
|
795
|
+
if (!this.audio.duration) return;
|
|
796
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
797
|
+
const x = event.clientX - rect.left;
|
|
798
|
+
const targetPercent = Math.max(0, Math.min(1, x / rect.width));
|
|
799
|
+
this.seekToPercent(targetPercent);
|
|
800
|
+
}
|
|
801
|
+
/**
|
|
802
|
+
* Set loading state
|
|
803
|
+
* @private
|
|
804
|
+
*/
|
|
805
|
+
setLoading(loading) {
|
|
806
|
+
this.isLoading = loading;
|
|
807
|
+
if (this.loadingEl) {
|
|
808
|
+
this.loadingEl.style.display = loading ? "block" : "none";
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
/**
|
|
812
|
+
* Handle metadata loaded
|
|
813
|
+
* @private
|
|
814
|
+
*/
|
|
815
|
+
onMetadataLoaded() {
|
|
816
|
+
if (this.totalTimeEl) {
|
|
817
|
+
this.totalTimeEl.textContent = formatTime(this.audio.duration);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
/**
|
|
821
|
+
* Handle play event
|
|
822
|
+
* @private
|
|
823
|
+
*/
|
|
824
|
+
onPlay() {
|
|
825
|
+
this.isPlaying = true;
|
|
826
|
+
this.playBtn.classList.add("playing");
|
|
827
|
+
const playIcon = this.playBtn.querySelector(".waveform-icon-play");
|
|
828
|
+
const pauseIcon = this.playBtn.querySelector(".waveform-icon-pause");
|
|
829
|
+
if (playIcon) playIcon.style.display = "none";
|
|
830
|
+
if (pauseIcon) pauseIcon.style.display = "flex";
|
|
831
|
+
this.startSmoothUpdate();
|
|
832
|
+
if (this.options.onPlay) {
|
|
833
|
+
this.options.onPlay(this);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
/**
|
|
837
|
+
* Handle pause event
|
|
838
|
+
* @private
|
|
839
|
+
*/
|
|
840
|
+
onPause() {
|
|
841
|
+
this.isPlaying = false;
|
|
842
|
+
this.playBtn.classList.remove("playing");
|
|
843
|
+
const playIcon = this.playBtn.querySelector(".waveform-icon-play");
|
|
844
|
+
const pauseIcon = this.playBtn.querySelector(".waveform-icon-pause");
|
|
845
|
+
if (playIcon) playIcon.style.display = "flex";
|
|
846
|
+
if (pauseIcon) pauseIcon.style.display = "none";
|
|
847
|
+
this.stopSmoothUpdate();
|
|
848
|
+
if (this.options.onPause) {
|
|
849
|
+
this.options.onPause(this);
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
/**
|
|
853
|
+
* Handle ended event
|
|
854
|
+
* @private
|
|
855
|
+
*/
|
|
856
|
+
onEnded() {
|
|
857
|
+
this.progress = 0;
|
|
858
|
+
this.audio.currentTime = 0;
|
|
859
|
+
this.drawWaveform();
|
|
860
|
+
if (this.currentTimeEl) {
|
|
861
|
+
this.currentTimeEl.textContent = "0:00";
|
|
862
|
+
}
|
|
863
|
+
this.onPause();
|
|
864
|
+
if (this.options.onEnd) {
|
|
865
|
+
this.options.onEnd(this);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
/**
|
|
869
|
+
* Handle error event
|
|
870
|
+
* @private
|
|
871
|
+
*/
|
|
872
|
+
onError(error) {
|
|
873
|
+
console.error("Audio error:", error);
|
|
874
|
+
this.hasError = true;
|
|
875
|
+
this.setLoading(false);
|
|
876
|
+
if (this.errorEl) {
|
|
877
|
+
this.errorEl.style.display = "flex";
|
|
878
|
+
}
|
|
879
|
+
if (this.canvas) {
|
|
880
|
+
this.canvas.style.opacity = "0.2";
|
|
881
|
+
}
|
|
882
|
+
if (this.playBtn) {
|
|
883
|
+
this.playBtn.disabled = true;
|
|
884
|
+
}
|
|
885
|
+
if (this.options.onError) {
|
|
886
|
+
this.options.onError(error, this);
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
/**
|
|
890
|
+
* Start smooth update animation
|
|
891
|
+
* @private
|
|
892
|
+
*/
|
|
893
|
+
startSmoothUpdate() {
|
|
894
|
+
this.stopSmoothUpdate();
|
|
895
|
+
const update = () => {
|
|
896
|
+
if (this.isPlaying && this.audio.duration) {
|
|
897
|
+
this.updateProgress();
|
|
898
|
+
this.updateTimer = requestAnimationFrame(update);
|
|
899
|
+
}
|
|
900
|
+
};
|
|
901
|
+
this.updateTimer = requestAnimationFrame(update);
|
|
902
|
+
}
|
|
903
|
+
/**
|
|
904
|
+
* Stop smooth update animation
|
|
905
|
+
* @private
|
|
906
|
+
*/
|
|
907
|
+
stopSmoothUpdate() {
|
|
908
|
+
if (this.updateTimer) {
|
|
909
|
+
cancelAnimationFrame(this.updateTimer);
|
|
910
|
+
this.updateTimer = null;
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
/**
|
|
914
|
+
* Update progress
|
|
915
|
+
* @private
|
|
916
|
+
*/
|
|
917
|
+
updateProgress() {
|
|
918
|
+
if (!this.audio.duration) return;
|
|
919
|
+
const newProgress = this.audio.currentTime / this.audio.duration;
|
|
920
|
+
if (Math.abs(newProgress - this.progress) > 1e-3) {
|
|
921
|
+
this.progress = newProgress;
|
|
922
|
+
this.drawWaveform();
|
|
923
|
+
}
|
|
924
|
+
if (this.currentTimeEl) {
|
|
925
|
+
this.currentTimeEl.textContent = formatTime(this.audio.currentTime);
|
|
926
|
+
}
|
|
927
|
+
if (this.options.onTimeUpdate) {
|
|
928
|
+
this.options.onTimeUpdate(this.audio.currentTime, this.audio.duration, this);
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
/**
|
|
932
|
+
* Update BPM display
|
|
933
|
+
* @private
|
|
934
|
+
*/
|
|
935
|
+
updateBPMDisplay() {
|
|
936
|
+
if (this.bpmEl && this.bpmValueEl && this.detectedBPM) {
|
|
937
|
+
this.bpmValueEl.textContent = Math.round(this.detectedBPM);
|
|
938
|
+
this.bpmEl.style.display = "inline-flex";
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
// ============================================
|
|
942
|
+
// Public API
|
|
943
|
+
// ============================================
|
|
944
|
+
/**
|
|
945
|
+
* Play audio
|
|
946
|
+
*/
|
|
947
|
+
play() {
|
|
948
|
+
if (this.options.singlePlay && _WaveformPlayer.currentlyPlaying && _WaveformPlayer.currentlyPlaying !== this) {
|
|
949
|
+
_WaveformPlayer.currentlyPlaying.pause();
|
|
950
|
+
}
|
|
951
|
+
_WaveformPlayer.currentlyPlaying = this;
|
|
952
|
+
this.audio.play();
|
|
953
|
+
}
|
|
954
|
+
/**
|
|
955
|
+
* Pause audio
|
|
956
|
+
*/
|
|
957
|
+
pause() {
|
|
958
|
+
if (_WaveformPlayer.currentlyPlaying === this) {
|
|
959
|
+
_WaveformPlayer.currentlyPlaying = null;
|
|
960
|
+
}
|
|
961
|
+
this.audio.pause();
|
|
962
|
+
}
|
|
963
|
+
/**
|
|
964
|
+
* Toggle play/pause
|
|
965
|
+
*/
|
|
966
|
+
togglePlay() {
|
|
967
|
+
if (this.isPlaying) {
|
|
968
|
+
this.pause();
|
|
969
|
+
} else {
|
|
970
|
+
this.play();
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
/**
|
|
974
|
+
* Seek to percentage
|
|
975
|
+
* @param {number} percent - Percentage (0-1)
|
|
976
|
+
*/
|
|
977
|
+
seekToPercent(percent) {
|
|
978
|
+
if (this.audio && this.audio.duration) {
|
|
979
|
+
this.audio.currentTime = this.audio.duration * Math.max(0, Math.min(1, percent));
|
|
980
|
+
this.updateProgress();
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
/**
|
|
984
|
+
* Set volume
|
|
985
|
+
* @param {number} volume - Volume (0-1)
|
|
986
|
+
*/
|
|
987
|
+
setVolume(volume) {
|
|
988
|
+
if (this.audio) {
|
|
989
|
+
this.audio.volume = Math.max(0, Math.min(1, volume));
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
/**
|
|
993
|
+
* Destroy player instance
|
|
994
|
+
*/
|
|
995
|
+
destroy() {
|
|
996
|
+
this.pause();
|
|
997
|
+
this.stopSmoothUpdate();
|
|
998
|
+
if (this.resizeObserver) {
|
|
999
|
+
this.resizeObserver.disconnect();
|
|
1000
|
+
}
|
|
1001
|
+
_WaveformPlayer.instances.delete(this.id);
|
|
1002
|
+
if (this.audio) {
|
|
1003
|
+
this.audio.src = "";
|
|
1004
|
+
}
|
|
1005
|
+
this.container.innerHTML = "";
|
|
1006
|
+
}
|
|
1007
|
+
// ============================================
|
|
1008
|
+
// Static methods
|
|
1009
|
+
// ============================================
|
|
1010
|
+
/**
|
|
1011
|
+
* Get player instance by ID, element, or element ID
|
|
1012
|
+
* @param {string|HTMLElement} idOrElement - Player ID, element, or element ID
|
|
1013
|
+
* @returns {WaveformPlayer|undefined}
|
|
1014
|
+
*/
|
|
1015
|
+
static getInstance(idOrElement) {
|
|
1016
|
+
if (typeof idOrElement === "string") {
|
|
1017
|
+
const instance = this.instances.get(idOrElement);
|
|
1018
|
+
if (instance) return instance;
|
|
1019
|
+
const element = document.getElementById(idOrElement);
|
|
1020
|
+
if (element) {
|
|
1021
|
+
return Array.from(this.instances.values()).find((p) => p.container === element);
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
if (idOrElement instanceof HTMLElement) {
|
|
1025
|
+
return Array.from(this.instances.values()).find((p) => p.container === idOrElement);
|
|
1026
|
+
}
|
|
1027
|
+
return void 0;
|
|
1028
|
+
}
|
|
1029
|
+
/**
|
|
1030
|
+
* Get all player instances
|
|
1031
|
+
* @returns {WaveformPlayer[]}
|
|
1032
|
+
*/
|
|
1033
|
+
static getAllInstances() {
|
|
1034
|
+
return Array.from(this.instances.values());
|
|
1035
|
+
}
|
|
1036
|
+
/**
|
|
1037
|
+
* Destroy all player instances
|
|
1038
|
+
*/
|
|
1039
|
+
static destroyAll() {
|
|
1040
|
+
this.instances.forEach((player) => player.destroy());
|
|
1041
|
+
this.instances.clear();
|
|
1042
|
+
}
|
|
1043
|
+
/**
|
|
1044
|
+
* Generate waveform data from audio URL
|
|
1045
|
+
* @static
|
|
1046
|
+
* @param {string} url - Audio URL
|
|
1047
|
+
* @param {number} samples - Number of samples
|
|
1048
|
+
* @returns {Promise<number[]>} Waveform peak data
|
|
1049
|
+
*/
|
|
1050
|
+
static async generateWaveformData(url, samples = 200) {
|
|
1051
|
+
try {
|
|
1052
|
+
const result = await generateWaveform(url, samples);
|
|
1053
|
+
return result.peaks;
|
|
1054
|
+
} catch (error) {
|
|
1055
|
+
console.error("Failed to generate waveform:", error);
|
|
1056
|
+
throw error;
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
};
|
|
1060
|
+
|
|
1061
|
+
// src/index.js
|
|
1062
|
+
function autoInit() {
|
|
1063
|
+
if (typeof document === "undefined") return;
|
|
1064
|
+
const elements = document.querySelectorAll("[data-waveform-player]");
|
|
1065
|
+
elements.forEach((element) => {
|
|
1066
|
+
if (element.dataset.waveformInitialized === "true") return;
|
|
1067
|
+
try {
|
|
1068
|
+
new WaveformPlayer(element);
|
|
1069
|
+
element.dataset.waveformInitialized = "true";
|
|
1070
|
+
} catch (error) {
|
|
1071
|
+
console.error("Failed to initialize WaveformPlayer:", error, element);
|
|
1072
|
+
}
|
|
1073
|
+
});
|
|
1074
|
+
}
|
|
1075
|
+
if (typeof document !== "undefined") {
|
|
1076
|
+
if (document.readyState === "loading") {
|
|
1077
|
+
document.addEventListener("DOMContentLoaded", autoInit);
|
|
1078
|
+
} else {
|
|
1079
|
+
autoInit();
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
WaveformPlayer.init = autoInit;
|
|
1083
|
+
if (typeof window !== "undefined") {
|
|
1084
|
+
window.WaveformPlayer = WaveformPlayer;
|
|
1085
|
+
}
|
|
1086
|
+
var index_default = WaveformPlayer;
|
|
1087
|
+
})();
|