@arraypress/waveform-player 1.0.1 → 1.1.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/js/core.js CHANGED
@@ -88,6 +88,10 @@ export class WaveformPlayer {
88
88
  this.init();
89
89
  }
90
90
 
91
+ // ============================================
92
+ // Initialization
93
+ // ============================================
94
+
91
95
  /**
92
96
  * Initialize the player
93
97
  * @private
@@ -95,6 +99,8 @@ export class WaveformPlayer {
95
99
  init() {
96
100
  this.createDOM();
97
101
  this.createAudio();
102
+ this.initPlaybackSpeed();
103
+ this.initKeyboardControls();
98
104
  this.bindEvents();
99
105
  this.setupResizeObserver();
100
106
 
@@ -126,47 +132,69 @@ export class WaveformPlayer {
126
132
 
127
133
  // Create HTML structure
128
134
  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>
135
+ <div class="waveform-player-inner">
136
+ <div class="waveform-body">
137
+ <div class="waveform-track">
138
+ <button class="waveform-btn" aria-label="Play/Pause" style="
139
+ border-color: ${this.options.buttonColor};
140
+ color: ${this.options.buttonColor};
141
+ ">
142
+ <span class="waveform-icon-play">${this.options.playIcon}</span>
143
+ <span class="waveform-icon-pause" style="display:none;">${this.options.pauseIcon}</span>
144
+ </button>
145
+
146
+ <div class="waveform-container">
147
+ <canvas></canvas>
148
+ <div class="waveform-markers"></div>
149
+ <div class="waveform-loading" style="display:none;"></div>
150
+ <div class="waveform-error" style="display:none;">
151
+ <span class="waveform-error-text">Unable to load audio</span>
147
152
  </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
- ` : ''}
153
+ </div>
154
+ </div>
155
+
156
+ <div class="waveform-info">
157
+ ${this.options.artwork ? `
158
+ <img class="waveform-artwork" src="${this.options.artwork}" alt="Album artwork" style="
159
+ width: 40px;
160
+ height: 40px;
161
+ border-radius: 4px;
162
+ object-fit: cover;
163
+ flex-shrink: 0;
164
+ ">
165
+ ` : ''}
166
+ <div class="waveform-text">
167
+ <span class="waveform-title" style="color: ${this.options.textColor};"></span>
168
+ ${this.options.subtitle ? `<span class="waveform-subtitle" style="color: ${this.options.textSecondaryColor};">${this.options.subtitle}</span>` : ''}
169
+ </div>
170
+ <div style="display: flex; align-items: center; gap: 1rem;">
171
+ ${this.options.showBPM ? `
172
+ <span class="waveform-bpm" style="color: ${this.options.textSecondaryColor}; display: none;">
173
+ <span class="bpm-value">--</span> BPM
174
+ </span>
175
+ ` : ''}
176
+ ${this.options.showPlaybackSpeed ? `
177
+ <div class="waveform-speed">
178
+ <button class="speed-btn" aria-label="Playback speed">
179
+ <span class="speed-value">1x</span>
180
+ </button>
181
+ <div class="speed-menu" style="display: none;">
182
+ ${this.options.playbackRates.map(rate =>
183
+ `<button class="speed-option" data-rate="${rate}">${rate}x</button>`
184
+ ).join('')}
185
+ </div>
165
186
  </div>
166
- </div>
187
+ ` : ''}
188
+ ${this.options.showTime ? `
189
+ <span class="waveform-time" style="color: ${this.options.textSecondaryColor};">
190
+ <span class="time-current">0:00</span> / <span class="time-total">0:00</span>
191
+ </span>
192
+ ` : ''}
167
193
  </div>
168
194
  </div>
169
- `;
195
+ </div>
196
+ </div>
197
+ `;
170
198
 
171
199
  // Get references
172
200
  this.playBtn = this.container.querySelector('.waveform-btn');
@@ -174,12 +202,16 @@ export class WaveformPlayer {
174
202
  this.ctx = this.canvas.getContext('2d');
175
203
  this.titleEl = this.container.querySelector('.waveform-title');
176
204
  this.subtitleEl = this.container.querySelector('.waveform-subtitle');
205
+ this.artworkEl = this.container.querySelector('.waveform-artwork');
177
206
  this.currentTimeEl = this.container.querySelector('.time-current');
178
207
  this.totalTimeEl = this.container.querySelector('.time-total');
179
208
  this.bpmEl = this.container.querySelector('.waveform-bpm');
180
209
  this.bpmValueEl = this.container.querySelector('.bpm-value');
181
210
  this.loadingEl = this.container.querySelector('.waveform-loading');
182
211
  this.errorEl = this.container.querySelector('.waveform-error');
212
+ this.markersContainer = this.container.querySelector('.waveform-markers');
213
+ this.speedBtn = this.container.querySelector('.speed-btn');
214
+ this.speedMenu = this.container.querySelector('.speed-menu');
183
215
 
184
216
  // Set canvas size
185
217
  this.resizeCanvas();
@@ -191,10 +223,155 @@ export class WaveformPlayer {
191
223
  */
192
224
  createAudio() {
193
225
  this.audio = new Audio();
194
- this.audio.preload = 'metadata';
226
+ this.audio.preload = this.options.preload || 'metadata';
195
227
  this.audio.crossOrigin = 'anonymous';
196
228
  }
197
229
 
230
+ // ============================================
231
+ // Feature Initialization
232
+ // ============================================
233
+
234
+ /**
235
+ * Initialize playback speed controls
236
+ * @private
237
+ */
238
+ initPlaybackSpeed() {
239
+ // Set initial playback rate if specified
240
+ if (this.options.playbackRate && this.options.playbackRate !== 1) {
241
+ this.audio.playbackRate = this.options.playbackRate;
242
+ }
243
+
244
+ // Initialize speed control UI if enabled
245
+ if (this.options.showPlaybackSpeed) {
246
+ this.initSpeedControls();
247
+ }
248
+ }
249
+
250
+ /**
251
+ * Initialize speed control UI
252
+ * @private
253
+ */
254
+ initSpeedControls() {
255
+ const speedBtn = this.container.querySelector('.speed-btn');
256
+ const speedMenu = this.container.querySelector('.speed-menu');
257
+
258
+ if (!speedBtn || !speedMenu) return;
259
+
260
+ // Toggle menu
261
+ speedBtn.addEventListener('click', (e) => {
262
+ e.stopPropagation();
263
+ speedMenu.style.display = speedMenu.style.display === 'none' ? 'block' : 'none';
264
+ });
265
+
266
+ // Close menu when clicking outside
267
+ document.addEventListener('click', () => {
268
+ speedMenu.style.display = 'none';
269
+ });
270
+
271
+ // Handle speed selection
272
+ speedMenu.addEventListener('click', (e) => {
273
+ e.stopPropagation();
274
+ if (e.target.classList.contains('speed-option')) {
275
+ const rate = parseFloat(e.target.dataset.rate);
276
+ this.setPlaybackRate(rate);
277
+ speedMenu.style.display = 'none';
278
+ }
279
+ });
280
+
281
+ // Set initial UI state
282
+ this.updateSpeedUI();
283
+ }
284
+
285
+ /**
286
+ * Initialize keyboard controls
287
+ * @private
288
+ */
289
+ initKeyboardControls() {
290
+ // Make container focusable but not in tab order by default
291
+ this.container.setAttribute('tabindex', '-1');
292
+
293
+ // Only activate keyboard controls when explicitly focused (clicked)
294
+ this.container.addEventListener('click', () => {
295
+ // Remove focus from all other players
296
+ WaveformPlayer.getAllInstances().forEach(player => {
297
+ if (player !== this) {
298
+ player.container.setAttribute('tabindex', '-1');
299
+ }
300
+ });
301
+ // Make this one focusable
302
+ this.container.setAttribute('tabindex', '0');
303
+ this.container.focus();
304
+ });
305
+
306
+ // Keyboard events
307
+ this.container.addEventListener('keydown', (e) => {
308
+ if (document.activeElement !== this.container) return;
309
+
310
+ const key = e.key;
311
+ const currentTime = this.audio.currentTime;
312
+
313
+ // Handle number keys 0-9 for seeking
314
+ if (key >= '0' && key <= '9') {
315
+ e.preventDefault();
316
+ this.seekToPercent(parseInt(key) / 10);
317
+ return;
318
+ }
319
+
320
+ // Handle other keys
321
+ const actions = {
322
+ ' ': () => this.togglePlay(),
323
+ 'ArrowLeft': () => this.seekTo(Math.max(0, currentTime - 5)),
324
+ 'ArrowRight': () => this.seekTo(Math.min(this.audio.duration, currentTime + 5)),
325
+ 'ArrowUp': () => this.setVolume(Math.min(1, this.audio.volume + 0.1)),
326
+ 'ArrowDown': () => this.setVolume(Math.max(0, this.audio.volume - 0.1)),
327
+ 'm': () => this.audio.muted = !this.audio.muted,
328
+ 'M': () => this.audio.muted = !this.audio.muted
329
+ };
330
+
331
+ if (actions[key]) {
332
+ e.preventDefault();
333
+ actions[key]();
334
+ }
335
+ });
336
+ }
337
+
338
+ /**
339
+ * Initialize Media Session API for system media controls
340
+ * @private
341
+ */
342
+ initMediaSession() {
343
+ if (!('mediaSession' in navigator) || !this.options.enableMediaSession) return;
344
+
345
+ // Set metadata
346
+ navigator.mediaSession.metadata = new MediaMetadata({
347
+ title: this.options.title || 'Unknown Track',
348
+ artist: this.options.subtitle || '',
349
+ album: this.options.album || '',
350
+ artwork: this.options.artwork ? [
351
+ {src: this.options.artwork, sizes: '512x512', type: 'image/jpeg'}
352
+ ] : []
353
+ });
354
+
355
+ // Set up action handlers
356
+ navigator.mediaSession.setActionHandler('play', () => this.play());
357
+ navigator.mediaSession.setActionHandler('pause', () => this.pause());
358
+ navigator.mediaSession.setActionHandler('seekbackward', () => {
359
+ this.seekTo(Math.max(0, this.audio.currentTime - 10));
360
+ });
361
+ navigator.mediaSession.setActionHandler('seekforward', () => {
362
+ this.seekTo(Math.min(this.audio.duration, this.audio.currentTime + 10));
363
+ });
364
+ navigator.mediaSession.setActionHandler('seekto', (details) => {
365
+ if (details.seekTime !== null) {
366
+ this.seekTo(details.seekTime);
367
+ }
368
+ });
369
+ }
370
+
371
+ // ============================================
372
+ // Event Binding
373
+ // ============================================
374
+
198
375
  /**
199
376
  * Bind event listeners
200
377
  * @private
@@ -235,6 +412,10 @@ export class WaveformPlayer {
235
412
  }
236
413
  }
237
414
 
415
+ // ============================================
416
+ // Audio Loading
417
+ // ============================================
418
+
238
419
  /**
239
420
  * Load audio file
240
421
  * @param {string} url - Audio URL
@@ -292,6 +473,8 @@ export class WaveformPlayer {
292
473
  }
293
474
 
294
475
  this.drawWaveform();
476
+ this.renderMarkers();
477
+ this.initMediaSession();
295
478
 
296
479
  // Fire callback
297
480
  if (this.options.onLoad) {
@@ -305,6 +488,84 @@ export class WaveformPlayer {
305
488
  }
306
489
  }
307
490
 
491
+ /**
492
+ * Load a new track
493
+ * @param {string} url - Audio URL
494
+ * @param {string} [title] - Track title
495
+ * @param {string} [subtitle] - Track subtitle
496
+ * @param {Object} [options] - Additional options
497
+ * @returns {Promise<void>}
498
+ */
499
+ async loadTrack(url, title = null, subtitle = null, options = {}) {
500
+ // Stop current playback and clear state
501
+ if (this.isPlaying) {
502
+ this.pause();
503
+ }
504
+
505
+ // Reset audio element completely
506
+ this.audio.src = '';
507
+ this.audio.load();
508
+
509
+ // Clear any errors
510
+ this.hasError = false;
511
+ if (this.errorEl) {
512
+ this.errorEl.style.display = 'none';
513
+ }
514
+ if (this.canvas) {
515
+ this.canvas.style.opacity = '1';
516
+ }
517
+ if (this.playBtn) {
518
+ this.playBtn.disabled = false;
519
+ }
520
+
521
+ // Reset state
522
+ this.progress = 0;
523
+ this.waveformData = [];
524
+
525
+ // Update options (including preload if specified)
526
+ this.options = mergeOptions(this.options, {
527
+ url,
528
+ title: title || this.options.title,
529
+ subtitle: subtitle || this.options.subtitle,
530
+ ...options
531
+ });
532
+
533
+ // Apply preload setting if it was changed
534
+ if (options.preload) {
535
+ this.audio.preload = options.preload;
536
+ }
537
+
538
+ // Update UI elements
539
+ if (this.subtitleEl) {
540
+ if (subtitle) {
541
+ this.subtitleEl.textContent = subtitle;
542
+ this.subtitleEl.style.display = '';
543
+ } else if (subtitle === '') {
544
+ this.subtitleEl.style.display = 'none';
545
+ }
546
+ }
547
+
548
+ // Update artwork if provided
549
+ if (options.artwork && this.artworkEl) {
550
+ this.artworkEl.src = options.artwork;
551
+ }
552
+
553
+ // Clear markers if new markers provided
554
+ if (options.markers) {
555
+ this.options.markers = options.markers;
556
+ }
557
+
558
+ // Load the new track
559
+ await this.load(url);
560
+
561
+ // Auto-play the new track
562
+ this.play();
563
+ }
564
+
565
+ // ============================================
566
+ // Visualization
567
+ // ============================================
568
+
308
569
  /**
309
570
  * Set waveform data
310
571
  * @private
@@ -354,6 +615,55 @@ export class WaveformPlayer {
354
615
  this.drawWaveform();
355
616
  }
356
617
 
618
+ /**
619
+ * Render markers on the waveform
620
+ * @private
621
+ */
622
+ renderMarkers() {
623
+ if (!this.options.showMarkers || !this.options.markers?.length || !this.markersContainer) return;
624
+
625
+ // Clear existing markers
626
+ this.markersContainer.innerHTML = '';
627
+
628
+ // Don't render if audio duration isn't available yet
629
+ if (!this.audio || !this.audio.duration || this.audio.duration === 0) {
630
+ return;
631
+ }
632
+
633
+ // Add each marker
634
+ this.options.markers.forEach((marker, index) => {
635
+ const position = (marker.time / this.audio.duration) * 100;
636
+
637
+ const markerEl = document.createElement('button');
638
+ markerEl.className = 'waveform-marker';
639
+ markerEl.style.left = `${position}%`;
640
+ markerEl.style.backgroundColor = marker.color || 'rgba(255, 255, 255, 0.5)';
641
+ markerEl.setAttribute('aria-label', marker.label);
642
+ markerEl.setAttribute('data-time', marker.time);
643
+
644
+ // Tooltip
645
+ const tooltip = document.createElement('span');
646
+ tooltip.className = 'waveform-marker-tooltip';
647
+ tooltip.textContent = marker.label;
648
+ markerEl.appendChild(tooltip);
649
+
650
+ // Click to seek
651
+ markerEl.addEventListener('click', (e) => {
652
+ e.stopPropagation();
653
+ this.seekTo(marker.time);
654
+ if (this.options.playOnSeek && !this.isPlaying) {
655
+ this.play();
656
+ }
657
+ });
658
+
659
+ this.markersContainer.appendChild(markerEl);
660
+ });
661
+ }
662
+
663
+ // ============================================
664
+ // Event Handlers
665
+ // ============================================
666
+
357
667
  /**
358
668
  * Handle canvas click
359
669
  * @private
@@ -387,6 +697,8 @@ export class WaveformPlayer {
387
697
  if (this.totalTimeEl) {
388
698
  this.totalTimeEl.textContent = formatTime(this.audio.duration);
389
699
  }
700
+ // Re-render markers when duration is known
701
+ this.renderMarkers();
390
702
  }
391
703
 
392
704
  /**
@@ -476,6 +788,10 @@ export class WaveformPlayer {
476
788
  }
477
789
  }
478
790
 
791
+ // ============================================
792
+ // Progress Updates
793
+ // ============================================
794
+
479
795
  /**
480
796
  * Start smooth update animation
481
797
  * @private
@@ -527,6 +843,10 @@ export class WaveformPlayer {
527
843
  }
528
844
  }
529
845
 
846
+ // ============================================
847
+ // UI Updates
848
+ // ============================================
849
+
530
850
  /**
531
851
  * Update BPM display
532
852
  * @private
@@ -538,6 +858,23 @@ export class WaveformPlayer {
538
858
  }
539
859
  }
540
860
 
861
+ /**
862
+ * Update speed UI to reflect current rate
863
+ * @private
864
+ */
865
+ updateSpeedUI() {
866
+ const speedValue = this.container.querySelector('.speed-value');
867
+ if (speedValue) {
868
+ const rate = this.audio.playbackRate;
869
+ speedValue.textContent = rate === 1 ? '1x' : `${rate}x`;
870
+ }
871
+
872
+ // Update active state in menu
873
+ this.container.querySelectorAll('.speed-option').forEach(btn => {
874
+ btn.classList.toggle('active', parseFloat(btn.dataset.rate) === this.audio.playbackRate);
875
+ });
876
+ }
877
+
541
878
  // ============================================
542
879
  // Public API
543
880
  // ============================================
@@ -576,6 +913,17 @@ export class WaveformPlayer {
576
913
  }
577
914
  }
578
915
 
916
+ /**
917
+ * Seek to time in seconds
918
+ * @param {number} seconds - Time in seconds
919
+ */
920
+ seekTo(seconds) {
921
+ if (this.audio && this.audio.duration) {
922
+ this.audio.currentTime = Math.max(0, Math.min(seconds, this.audio.duration));
923
+ this.updateProgress();
924
+ }
925
+ }
926
+
579
927
  /**
580
928
  * Seek to percentage
581
929
  * @param {number} percent - Percentage (0-1)
@@ -597,6 +945,20 @@ export class WaveformPlayer {
597
945
  }
598
946
  }
599
947
 
948
+ /**
949
+ * Set playback rate
950
+ * @param {number} rate - Playback rate (0.5 to 2)
951
+ */
952
+ setPlaybackRate(rate) {
953
+ if (!this.audio) return;
954
+
955
+ const clampedRate = Math.max(0.5, Math.min(2, rate));
956
+ this.audio.playbackRate = clampedRate;
957
+ this.options.playbackRate = clampedRate;
958
+
959
+ this.updateSpeedUI();
960
+ }
961
+
600
962
  /**
601
963
  * Destroy player instance
602
964
  */
@@ -618,7 +980,7 @@ export class WaveformPlayer {
618
980
  }
619
981
 
620
982
  // ============================================
621
- // Static methods
983
+ // Static Methods
622
984
  // ============================================
623
985
 
624
986
  /**
@@ -676,5 +1038,4 @@ export class WaveformPlayer {
676
1038
  throw error;
677
1039
  }
678
1040
  }
679
-
680
1041
  }
package/src/js/themes.js CHANGED
@@ -37,6 +37,14 @@ export const DEFAULT_OPTIONS = {
37
37
  url: '',
38
38
  height: 60,
39
39
  samples: 200,
40
+ preload: 'metadata',
41
+
42
+ // Playback
43
+ playbackRate: 1,
44
+ showPlaybackSpeed: false,
45
+ playbackRates: [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2], // Available speeds
46
+
47
+
40
48
 
41
49
  // Default waveform style
42
50
  waveformStyle: 'mirror',
@@ -63,10 +71,17 @@ export const DEFAULT_OPTIONS = {
63
71
  showBPM: false,
64
72
  singlePlay: true,
65
73
  playOnSeek: true,
74
+ enableMediaSession: true,
75
+
76
+ // Markers
77
+ markers: [],
78
+ showMarkers: true,
66
79
 
67
80
  // Content
68
81
  title: null,
69
82
  subtitle: null,
83
+ artwork: null,
84
+ album: '',
70
85
 
71
86
  // Icons (SVG)
72
87
  playIcon: '<svg viewBox="0 0 24 24" width="16" height="16"><path d="M8 5v14l11-7z"/></svg>',
package/src/js/utils.js CHANGED
@@ -15,6 +15,9 @@ export function parseDataAttributes(element) {
15
15
  if (element.dataset.url) options.url = element.dataset.url;
16
16
  if (element.dataset.height) options.height = parseInt(element.dataset.height);
17
17
  if (element.dataset.samples) options.samples = parseInt(element.dataset.samples);
18
+ if (element.dataset.preload) {
19
+ options.preload = element.dataset.preload;
20
+ }
18
21
 
19
22
  // Waveform style attributes
20
23
  if (element.dataset.waveformStyle) options.waveformStyle = element.dataset.waveformStyle;
@@ -46,13 +49,44 @@ export function parseDataAttributes(element) {
46
49
  if (element.dataset.singlePlay) options.singlePlay = element.dataset.singlePlay === 'true';
47
50
  if (element.dataset.playOnSeek) options.playOnSeek = element.dataset.playOnSeek === 'true';
48
51
 
49
- // Content
52
+ // Content and metadata
50
53
  if (element.dataset.title) options.title = element.dataset.title;
51
54
  if (element.dataset.subtitle) options.subtitle = element.dataset.subtitle;
55
+ if (element.dataset.album) options.album = element.dataset.album;
56
+ if (element.dataset.artwork) options.artwork = element.dataset.artwork;
52
57
 
53
58
  // Waveform data
54
59
  if (element.dataset.waveform) options.waveform = element.dataset.waveform;
55
60
 
61
+ // Markers
62
+ if (element.dataset.markers) {
63
+ try {
64
+ options.markers = JSON.parse(element.dataset.markers);
65
+ } catch (e) {
66
+ console.warn('Invalid markers JSON:', e);
67
+ }
68
+ }
69
+
70
+ // Playback controls
71
+ if (element.dataset.playbackRate) {
72
+ options.playbackRate = parseFloat(element.dataset.playbackRate);
73
+ }
74
+ if (element.dataset.showPlaybackSpeed !== undefined) {
75
+ options.showPlaybackSpeed = element.dataset.showPlaybackSpeed === 'true';
76
+ }
77
+ if (element.dataset.playbackRates) {
78
+ try {
79
+ options.playbackRates = JSON.parse(element.dataset.playbackRates);
80
+ } catch (e) {
81
+ console.warn('Invalid playbackRates JSON:', e);
82
+ }
83
+ }
84
+
85
+ // Media Session API
86
+ if (element.dataset.enableMediaSession !== undefined) {
87
+ options.enableMediaSession = element.dataset.enableMediaSession === 'true';
88
+ }
89
+
56
90
  return options;
57
91
  }
58
92