@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/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
+ }