@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
package/src/core.js
ADDED
|
@@ -0,0 +1,680 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module core
|
|
3
|
+
* @description Main WaveformPlayer class
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {draw} from './drawing.js';
|
|
7
|
+
import {generateWaveform, generatePlaceholderWaveform} from './audio.js';
|
|
8
|
+
import {
|
|
9
|
+
formatTime,
|
|
10
|
+
extractTitleFromUrl,
|
|
11
|
+
generateId,
|
|
12
|
+
parseDataAttributes,
|
|
13
|
+
mergeOptions,
|
|
14
|
+
debounce
|
|
15
|
+
} from './utils.js';
|
|
16
|
+
|
|
17
|
+
import {DEFAULT_OPTIONS, STYLE_DEFAULTS} from './themes.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* WaveformPlayer - Modern audio player with waveform visualization
|
|
21
|
+
* @class
|
|
22
|
+
*/
|
|
23
|
+
export class WaveformPlayer {
|
|
24
|
+
/** @type {Map<string, WaveformPlayer>} */
|
|
25
|
+
static instances = new Map();
|
|
26
|
+
|
|
27
|
+
/** @type {WaveformPlayer|null} */
|
|
28
|
+
static currentlyPlaying = null;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Create a new WaveformPlayer instance
|
|
32
|
+
* @param {string|HTMLElement} container - Container element or selector
|
|
33
|
+
* @param {Object} options - Player options
|
|
34
|
+
*/
|
|
35
|
+
constructor(container, options = {}) {
|
|
36
|
+
// Resolve container
|
|
37
|
+
this.container = typeof container === 'string'
|
|
38
|
+
? document.querySelector(container)
|
|
39
|
+
: container;
|
|
40
|
+
|
|
41
|
+
if (!this.container) {
|
|
42
|
+
throw new Error('WaveformPlayer: Container element not found');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Parse data attributes if present
|
|
46
|
+
const dataOptions = parseDataAttributes(this.container);
|
|
47
|
+
|
|
48
|
+
// Merge options: defaults < data attributes < constructor options
|
|
49
|
+
this.options = mergeOptions(DEFAULT_OPTIONS, dataOptions, options);
|
|
50
|
+
|
|
51
|
+
// Apply style-specific defaults if not explicitly set
|
|
52
|
+
const styleDefaults = STYLE_DEFAULTS[this.options.waveformStyle];
|
|
53
|
+
if (styleDefaults) {
|
|
54
|
+
if (dataOptions.barWidth === undefined && options.barWidth === undefined) {
|
|
55
|
+
this.options.barWidth = styleDefaults.barWidth;
|
|
56
|
+
}
|
|
57
|
+
if (dataOptions.barSpacing === undefined && options.barSpacing === undefined) {
|
|
58
|
+
this.options.barSpacing = styleDefaults.barSpacing;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Set default colors if not provided
|
|
63
|
+
this.options.waveformColor = this.options.waveformColor || 'rgba(255, 255, 255, 0.3)';
|
|
64
|
+
this.options.progressColor = this.options.progressColor || 'rgba(255, 255, 255, 0.9)';
|
|
65
|
+
this.options.buttonColor = this.options.buttonColor || 'rgba(255, 255, 255, 0.9)';
|
|
66
|
+
this.options.textColor = this.options.textColor || '#ffffff';
|
|
67
|
+
this.options.textSecondaryColor = this.options.textSecondaryColor || 'rgba(255, 255, 255, 0.6)';
|
|
68
|
+
|
|
69
|
+
// Initialize state
|
|
70
|
+
this.audio = null;
|
|
71
|
+
this.canvas = null;
|
|
72
|
+
this.ctx = null;
|
|
73
|
+
this.waveformData = [];
|
|
74
|
+
this.progress = 0;
|
|
75
|
+
this.isPlaying = false;
|
|
76
|
+
this.isLoading = false;
|
|
77
|
+
this.hasError = false;
|
|
78
|
+
this.updateTimer = null;
|
|
79
|
+
this.resizeObserver = null;
|
|
80
|
+
|
|
81
|
+
// Generate unique ID
|
|
82
|
+
this.id = this.container.id || generateId(this.options.url);
|
|
83
|
+
|
|
84
|
+
// Add to instances
|
|
85
|
+
WaveformPlayer.instances.set(this.id, this);
|
|
86
|
+
|
|
87
|
+
// Initialize
|
|
88
|
+
this.init();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Initialize the player
|
|
93
|
+
* @private
|
|
94
|
+
*/
|
|
95
|
+
init() {
|
|
96
|
+
this.createDOM();
|
|
97
|
+
this.createAudio();
|
|
98
|
+
this.bindEvents();
|
|
99
|
+
this.setupResizeObserver();
|
|
100
|
+
|
|
101
|
+
// Ensure proper sizing after DOM is ready
|
|
102
|
+
requestAnimationFrame(() => {
|
|
103
|
+
this.resizeCanvas();
|
|
104
|
+
|
|
105
|
+
// Load audio if URL provided
|
|
106
|
+
if (this.options.url) {
|
|
107
|
+
this.load(this.options.url).then(() => {
|
|
108
|
+
if (this.options.autoplay) {
|
|
109
|
+
this.play();
|
|
110
|
+
}
|
|
111
|
+
}).catch(error => {
|
|
112
|
+
console.error('Failed to load audio:', error);
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Create DOM elements
|
|
120
|
+
* @private
|
|
121
|
+
*/
|
|
122
|
+
createDOM() {
|
|
123
|
+
// Clear container
|
|
124
|
+
this.container.innerHTML = '';
|
|
125
|
+
this.container.className = 'waveform-player';
|
|
126
|
+
|
|
127
|
+
// Create HTML structure
|
|
128
|
+
this.container.innerHTML = `
|
|
129
|
+
<div class="waveform-player-inner">
|
|
130
|
+
<div class="waveform-body">
|
|
131
|
+
<div class="waveform-track">
|
|
132
|
+
<button class="waveform-btn" aria-label="Play/Pause" style="
|
|
133
|
+
border-color: ${this.options.buttonColor};
|
|
134
|
+
color: ${this.options.buttonColor};
|
|
135
|
+
">
|
|
136
|
+
<span class="waveform-icon-play">${this.options.playIcon}</span>
|
|
137
|
+
<span class="waveform-icon-pause" style="display:none;">${this.options.pauseIcon}</span>
|
|
138
|
+
</button>
|
|
139
|
+
|
|
140
|
+
<div class="waveform-container">
|
|
141
|
+
<canvas></canvas>
|
|
142
|
+
<div class="waveform-loading" style="display:none;"></div>
|
|
143
|
+
<div class="waveform-error" style="display:none;">
|
|
144
|
+
<span class="waveform-error-text">Unable to load audio</span>
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
|
|
149
|
+
<div class="waveform-info">
|
|
150
|
+
<div class="waveform-text">
|
|
151
|
+
<span class="waveform-title" style="color: ${this.options.textColor};"></span>
|
|
152
|
+
${this.options.subtitle ? `<span class="waveform-subtitle" style="color: ${this.options.textSecondaryColor};">${this.options.subtitle}</span>` : ''}
|
|
153
|
+
</div>
|
|
154
|
+
<div style="display: flex; align-items: center; gap: 1rem;">
|
|
155
|
+
${this.options.showBPM ? `
|
|
156
|
+
<span class="waveform-bpm" style="color: ${this.options.textSecondaryColor}; display: none;">
|
|
157
|
+
<span class="bpm-value">--</span> BPM
|
|
158
|
+
</span>
|
|
159
|
+
` : ''}
|
|
160
|
+
${this.options.showTime ? `
|
|
161
|
+
<span class="waveform-time" style="color: ${this.options.textSecondaryColor};">
|
|
162
|
+
<span class="time-current">0:00</span> / <span class="time-total">0:00</span>
|
|
163
|
+
</span>
|
|
164
|
+
` : ''}
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
`;
|
|
170
|
+
|
|
171
|
+
// Get references
|
|
172
|
+
this.playBtn = this.container.querySelector('.waveform-btn');
|
|
173
|
+
this.canvas = this.container.querySelector('canvas');
|
|
174
|
+
this.ctx = this.canvas.getContext('2d');
|
|
175
|
+
this.titleEl = this.container.querySelector('.waveform-title');
|
|
176
|
+
this.subtitleEl = this.container.querySelector('.waveform-subtitle');
|
|
177
|
+
this.currentTimeEl = this.container.querySelector('.time-current');
|
|
178
|
+
this.totalTimeEl = this.container.querySelector('.time-total');
|
|
179
|
+
this.bpmEl = this.container.querySelector('.waveform-bpm');
|
|
180
|
+
this.bpmValueEl = this.container.querySelector('.bpm-value');
|
|
181
|
+
this.loadingEl = this.container.querySelector('.waveform-loading');
|
|
182
|
+
this.errorEl = this.container.querySelector('.waveform-error');
|
|
183
|
+
|
|
184
|
+
// Set canvas size
|
|
185
|
+
this.resizeCanvas();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Create audio element
|
|
190
|
+
* @private
|
|
191
|
+
*/
|
|
192
|
+
createAudio() {
|
|
193
|
+
this.audio = new Audio();
|
|
194
|
+
this.audio.preload = 'metadata';
|
|
195
|
+
this.audio.crossOrigin = 'anonymous';
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Bind event listeners
|
|
200
|
+
* @private
|
|
201
|
+
*/
|
|
202
|
+
bindEvents() {
|
|
203
|
+
// Play button
|
|
204
|
+
this.playBtn.addEventListener('click', () => this.togglePlay());
|
|
205
|
+
|
|
206
|
+
// Audio events
|
|
207
|
+
this.audio.addEventListener('loadstart', () => this.setLoading(true));
|
|
208
|
+
this.audio.addEventListener('loadedmetadata', () => this.onMetadataLoaded());
|
|
209
|
+
this.audio.addEventListener('canplay', () => this.setLoading(false));
|
|
210
|
+
this.audio.addEventListener('play', () => this.onPlay());
|
|
211
|
+
this.audio.addEventListener('pause', () => this.onPause());
|
|
212
|
+
this.audio.addEventListener('ended', () => this.onEnded());
|
|
213
|
+
this.audio.addEventListener('error', (e) => this.onError(e));
|
|
214
|
+
|
|
215
|
+
// Canvas interactions
|
|
216
|
+
this.canvas.addEventListener('click', (e) => this.handleCanvasClick(e));
|
|
217
|
+
|
|
218
|
+
// Window resize
|
|
219
|
+
window.addEventListener('resize', debounce(() => this.resizeCanvas(), 100));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Setup resize observer
|
|
224
|
+
* @private
|
|
225
|
+
*/
|
|
226
|
+
setupResizeObserver() {
|
|
227
|
+
if ('ResizeObserver' in window) {
|
|
228
|
+
this.resizeObserver = new ResizeObserver(() => {
|
|
229
|
+
this.resizeCanvas();
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
if (this.canvas?.parentElement) {
|
|
233
|
+
this.resizeObserver.observe(this.canvas.parentElement);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Load audio file
|
|
240
|
+
* @param {string} url - Audio URL
|
|
241
|
+
* @returns {Promise<void>}
|
|
242
|
+
*/
|
|
243
|
+
async load(url) {
|
|
244
|
+
try {
|
|
245
|
+
this.setLoading(true);
|
|
246
|
+
this.progress = 0;
|
|
247
|
+
this.hasError = false;
|
|
248
|
+
|
|
249
|
+
// Set audio source
|
|
250
|
+
this.audio.src = url;
|
|
251
|
+
|
|
252
|
+
// Wait for metadata to load
|
|
253
|
+
await new Promise((resolve, reject) => {
|
|
254
|
+
const metadataHandler = () => {
|
|
255
|
+
this.audio.removeEventListener('loadedmetadata', metadataHandler);
|
|
256
|
+
this.audio.removeEventListener('error', errorHandler);
|
|
257
|
+
resolve();
|
|
258
|
+
};
|
|
259
|
+
const errorHandler = (e) => {
|
|
260
|
+
this.audio.removeEventListener('loadedmetadata', metadataHandler);
|
|
261
|
+
this.audio.removeEventListener('error', errorHandler);
|
|
262
|
+
reject(e);
|
|
263
|
+
};
|
|
264
|
+
this.audio.addEventListener('loadedmetadata', metadataHandler);
|
|
265
|
+
this.audio.addEventListener('error', errorHandler);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// Set title
|
|
269
|
+
const title = this.options.title || extractTitleFromUrl(url);
|
|
270
|
+
if (this.titleEl) {
|
|
271
|
+
this.titleEl.textContent = title;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Load or generate waveform
|
|
275
|
+
if (this.options.waveform) {
|
|
276
|
+
this.setWaveformData(this.options.waveform);
|
|
277
|
+
} else {
|
|
278
|
+
// Generate waveform
|
|
279
|
+
try {
|
|
280
|
+
const result = await generateWaveform(url, this.options.samples, this.options.showBPM);
|
|
281
|
+
this.waveformData = result.peaks;
|
|
282
|
+
|
|
283
|
+
// Store BPM if detected
|
|
284
|
+
if (result.bpm) {
|
|
285
|
+
this.detectedBPM = result.bpm;
|
|
286
|
+
this.updateBPMDisplay();
|
|
287
|
+
}
|
|
288
|
+
} catch (error) {
|
|
289
|
+
console.warn('Using placeholder waveform:', error);
|
|
290
|
+
this.waveformData = generatePlaceholderWaveform(this.options.samples);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
this.drawWaveform();
|
|
295
|
+
|
|
296
|
+
// Fire callback
|
|
297
|
+
if (this.options.onLoad) {
|
|
298
|
+
this.options.onLoad(this);
|
|
299
|
+
}
|
|
300
|
+
} catch (error) {
|
|
301
|
+
console.error('Failed to load audio:', error);
|
|
302
|
+
this.onError(error);
|
|
303
|
+
} finally {
|
|
304
|
+
this.setLoading(false);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Set waveform data
|
|
310
|
+
* @private
|
|
311
|
+
*/
|
|
312
|
+
setWaveformData(data) {
|
|
313
|
+
if (typeof data === 'string') {
|
|
314
|
+
try {
|
|
315
|
+
const parsed = JSON.parse(data);
|
|
316
|
+
this.waveformData = Array.isArray(parsed) ? parsed : [];
|
|
317
|
+
} catch {
|
|
318
|
+
this.waveformData = data.split(',').map(Number);
|
|
319
|
+
}
|
|
320
|
+
} else {
|
|
321
|
+
this.waveformData = Array.isArray(data) ? data : [];
|
|
322
|
+
}
|
|
323
|
+
this.drawWaveform();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Draw waveform
|
|
328
|
+
* @private
|
|
329
|
+
*/
|
|
330
|
+
drawWaveform() {
|
|
331
|
+
if (!this.ctx || this.waveformData.length === 0) return;
|
|
332
|
+
|
|
333
|
+
draw(this.ctx, this.canvas, this.waveformData, this.progress, {
|
|
334
|
+
...this.options,
|
|
335
|
+
waveformStyle: this.options.waveformStyle || 'bars',
|
|
336
|
+
color: this.options.waveformColor,
|
|
337
|
+
progressColor: this.options.progressColor
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Resize canvas
|
|
343
|
+
* @private
|
|
344
|
+
*/
|
|
345
|
+
resizeCanvas() {
|
|
346
|
+
const dpr = window.devicePixelRatio || 1;
|
|
347
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
348
|
+
|
|
349
|
+
this.canvas.width = rect.width * dpr;
|
|
350
|
+
this.canvas.height = this.options.height * dpr;
|
|
351
|
+
this.canvas.style.height = this.options.height + 'px';
|
|
352
|
+
this.canvas.parentElement.style.height = this.options.height + 'px';
|
|
353
|
+
|
|
354
|
+
this.drawWaveform();
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Handle canvas click
|
|
359
|
+
* @private
|
|
360
|
+
*/
|
|
361
|
+
handleCanvasClick(event) {
|
|
362
|
+
if (!this.audio.duration) return;
|
|
363
|
+
|
|
364
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
365
|
+
const x = event.clientX - rect.left;
|
|
366
|
+
const targetPercent = Math.max(0, Math.min(1, x / rect.width));
|
|
367
|
+
|
|
368
|
+
this.seekToPercent(targetPercent);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Set loading state
|
|
373
|
+
* @private
|
|
374
|
+
*/
|
|
375
|
+
setLoading(loading) {
|
|
376
|
+
this.isLoading = loading;
|
|
377
|
+
if (this.loadingEl) {
|
|
378
|
+
this.loadingEl.style.display = loading ? 'block' : 'none';
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Handle metadata loaded
|
|
384
|
+
* @private
|
|
385
|
+
*/
|
|
386
|
+
onMetadataLoaded() {
|
|
387
|
+
if (this.totalTimeEl) {
|
|
388
|
+
this.totalTimeEl.textContent = formatTime(this.audio.duration);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Handle play event
|
|
394
|
+
* @private
|
|
395
|
+
*/
|
|
396
|
+
onPlay() {
|
|
397
|
+
this.isPlaying = true;
|
|
398
|
+
this.playBtn.classList.add('playing');
|
|
399
|
+
|
|
400
|
+
const playIcon = this.playBtn.querySelector('.waveform-icon-play');
|
|
401
|
+
const pauseIcon = this.playBtn.querySelector('.waveform-icon-pause');
|
|
402
|
+
if (playIcon) playIcon.style.display = 'none';
|
|
403
|
+
if (pauseIcon) pauseIcon.style.display = 'flex';
|
|
404
|
+
|
|
405
|
+
this.startSmoothUpdate();
|
|
406
|
+
|
|
407
|
+
if (this.options.onPlay) {
|
|
408
|
+
this.options.onPlay(this);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Handle pause event
|
|
414
|
+
* @private
|
|
415
|
+
*/
|
|
416
|
+
onPause() {
|
|
417
|
+
this.isPlaying = false;
|
|
418
|
+
this.playBtn.classList.remove('playing');
|
|
419
|
+
|
|
420
|
+
const playIcon = this.playBtn.querySelector('.waveform-icon-play');
|
|
421
|
+
const pauseIcon = this.playBtn.querySelector('.waveform-icon-pause');
|
|
422
|
+
if (playIcon) playIcon.style.display = 'flex';
|
|
423
|
+
if (pauseIcon) pauseIcon.style.display = 'none';
|
|
424
|
+
|
|
425
|
+
this.stopSmoothUpdate();
|
|
426
|
+
|
|
427
|
+
if (this.options.onPause) {
|
|
428
|
+
this.options.onPause(this);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Handle ended event
|
|
434
|
+
* @private
|
|
435
|
+
*/
|
|
436
|
+
onEnded() {
|
|
437
|
+
this.progress = 0;
|
|
438
|
+
this.audio.currentTime = 0;
|
|
439
|
+
this.drawWaveform();
|
|
440
|
+
|
|
441
|
+
// Reset time display
|
|
442
|
+
if (this.currentTimeEl) {
|
|
443
|
+
this.currentTimeEl.textContent = '0:00';
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
this.onPause();
|
|
447
|
+
|
|
448
|
+
if (this.options.onEnd) {
|
|
449
|
+
this.options.onEnd(this);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Handle error event
|
|
455
|
+
* @private
|
|
456
|
+
*/
|
|
457
|
+
onError(error) {
|
|
458
|
+
console.error('Audio error:', error);
|
|
459
|
+
this.hasError = true;
|
|
460
|
+
this.setLoading(false);
|
|
461
|
+
|
|
462
|
+
if (this.errorEl) {
|
|
463
|
+
this.errorEl.style.display = 'flex';
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
if (this.canvas) {
|
|
467
|
+
this.canvas.style.opacity = '0.2';
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (this.playBtn) {
|
|
471
|
+
this.playBtn.disabled = true;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (this.options.onError) {
|
|
475
|
+
this.options.onError(error, this);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Start smooth update animation
|
|
481
|
+
* @private
|
|
482
|
+
*/
|
|
483
|
+
startSmoothUpdate() {
|
|
484
|
+
this.stopSmoothUpdate();
|
|
485
|
+
|
|
486
|
+
const update = () => {
|
|
487
|
+
if (this.isPlaying && this.audio.duration) {
|
|
488
|
+
this.updateProgress();
|
|
489
|
+
this.updateTimer = requestAnimationFrame(update);
|
|
490
|
+
}
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
this.updateTimer = requestAnimationFrame(update);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Stop smooth update animation
|
|
498
|
+
* @private
|
|
499
|
+
*/
|
|
500
|
+
stopSmoothUpdate() {
|
|
501
|
+
if (this.updateTimer) {
|
|
502
|
+
cancelAnimationFrame(this.updateTimer);
|
|
503
|
+
this.updateTimer = null;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Update progress
|
|
509
|
+
* @private
|
|
510
|
+
*/
|
|
511
|
+
updateProgress() {
|
|
512
|
+
if (!this.audio.duration) return;
|
|
513
|
+
|
|
514
|
+
const newProgress = this.audio.currentTime / this.audio.duration;
|
|
515
|
+
|
|
516
|
+
if (Math.abs(newProgress - this.progress) > 0.001) {
|
|
517
|
+
this.progress = newProgress;
|
|
518
|
+
this.drawWaveform();
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
if (this.currentTimeEl) {
|
|
522
|
+
this.currentTimeEl.textContent = formatTime(this.audio.currentTime);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
if (this.options.onTimeUpdate) {
|
|
526
|
+
this.options.onTimeUpdate(this.audio.currentTime, this.audio.duration, this);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Update BPM display
|
|
532
|
+
* @private
|
|
533
|
+
*/
|
|
534
|
+
updateBPMDisplay() {
|
|
535
|
+
if (this.bpmEl && this.bpmValueEl && this.detectedBPM) {
|
|
536
|
+
this.bpmValueEl.textContent = Math.round(this.detectedBPM);
|
|
537
|
+
this.bpmEl.style.display = 'inline-flex';
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// ============================================
|
|
542
|
+
// Public API
|
|
543
|
+
// ============================================
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Play audio
|
|
547
|
+
*/
|
|
548
|
+
play() {
|
|
549
|
+
if (this.options.singlePlay && WaveformPlayer.currentlyPlaying &&
|
|
550
|
+
WaveformPlayer.currentlyPlaying !== this) {
|
|
551
|
+
WaveformPlayer.currentlyPlaying.pause();
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
WaveformPlayer.currentlyPlaying = this;
|
|
555
|
+
this.audio.play();
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Pause audio
|
|
560
|
+
*/
|
|
561
|
+
pause() {
|
|
562
|
+
if (WaveformPlayer.currentlyPlaying === this) {
|
|
563
|
+
WaveformPlayer.currentlyPlaying = null;
|
|
564
|
+
}
|
|
565
|
+
this.audio.pause();
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Toggle play/pause
|
|
570
|
+
*/
|
|
571
|
+
togglePlay() {
|
|
572
|
+
if (this.isPlaying) {
|
|
573
|
+
this.pause();
|
|
574
|
+
} else {
|
|
575
|
+
this.play();
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Seek to percentage
|
|
581
|
+
* @param {number} percent - Percentage (0-1)
|
|
582
|
+
*/
|
|
583
|
+
seekToPercent(percent) {
|
|
584
|
+
if (this.audio && this.audio.duration) {
|
|
585
|
+
this.audio.currentTime = this.audio.duration * Math.max(0, Math.min(1, percent));
|
|
586
|
+
this.updateProgress();
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Set volume
|
|
592
|
+
* @param {number} volume - Volume (0-1)
|
|
593
|
+
*/
|
|
594
|
+
setVolume(volume) {
|
|
595
|
+
if (this.audio) {
|
|
596
|
+
this.audio.volume = Math.max(0, Math.min(1, volume));
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Destroy player instance
|
|
602
|
+
*/
|
|
603
|
+
destroy() {
|
|
604
|
+
this.pause();
|
|
605
|
+
this.stopSmoothUpdate();
|
|
606
|
+
|
|
607
|
+
if (this.resizeObserver) {
|
|
608
|
+
this.resizeObserver.disconnect();
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
WaveformPlayer.instances.delete(this.id);
|
|
612
|
+
|
|
613
|
+
if (this.audio) {
|
|
614
|
+
this.audio.src = '';
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
this.container.innerHTML = '';
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// ============================================
|
|
621
|
+
// Static methods
|
|
622
|
+
// ============================================
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Get player instance by ID, element, or element ID
|
|
626
|
+
* @param {string|HTMLElement} idOrElement - Player ID, element, or element ID
|
|
627
|
+
* @returns {WaveformPlayer|undefined}
|
|
628
|
+
*/
|
|
629
|
+
static getInstance(idOrElement) {
|
|
630
|
+
if (typeof idOrElement === 'string') {
|
|
631
|
+
const instance = this.instances.get(idOrElement);
|
|
632
|
+
if (instance) return instance;
|
|
633
|
+
|
|
634
|
+
const element = document.getElementById(idOrElement);
|
|
635
|
+
if (element) {
|
|
636
|
+
return Array.from(this.instances.values()).find(p => p.container === element);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
if (idOrElement instanceof HTMLElement) {
|
|
641
|
+
return Array.from(this.instances.values()).find(p => p.container === idOrElement);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
return undefined;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Get all player instances
|
|
649
|
+
* @returns {WaveformPlayer[]}
|
|
650
|
+
*/
|
|
651
|
+
static getAllInstances() {
|
|
652
|
+
return Array.from(this.instances.values());
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Destroy all player instances
|
|
657
|
+
*/
|
|
658
|
+
static destroyAll() {
|
|
659
|
+
this.instances.forEach(player => player.destroy());
|
|
660
|
+
this.instances.clear();
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Generate waveform data from audio URL
|
|
665
|
+
* @static
|
|
666
|
+
* @param {string} url - Audio URL
|
|
667
|
+
* @param {number} samples - Number of samples
|
|
668
|
+
* @returns {Promise<number[]>} Waveform peak data
|
|
669
|
+
*/
|
|
670
|
+
static async generateWaveformData(url, samples = 200) {
|
|
671
|
+
try {
|
|
672
|
+
const result = await generateWaveform(url, samples);
|
|
673
|
+
return result.peaks;
|
|
674
|
+
} catch (error) {
|
|
675
|
+
console.error('Failed to generate waveform:', error);
|
|
676
|
+
throw error;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
}
|