@arraypress/waveform-player 1.0.1 → 1.1.1
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/dist/waveform-player.css +1 -1
- package/dist/waveform-player.esm.js +58 -38
- package/dist/waveform-player.js +390 -54
- package/dist/waveform-player.min.js +58 -38
- package/package.json +2 -2
- package/src/css/waveform-player.css +197 -10
- package/src/js/audio.js +37 -19
- package/src/js/core.js +414 -40
- package/src/js/themes.js +16 -0
- package/src/js/utils.js +36 -1
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
|
|
|
@@ -124,49 +130,84 @@ export class WaveformPlayer {
|
|
|
124
130
|
this.container.innerHTML = '';
|
|
125
131
|
this.container.className = 'waveform-player';
|
|
126
132
|
|
|
133
|
+
// Determine button alignment
|
|
134
|
+
// Determine button alignment
|
|
135
|
+
let buttonAlign = this.options.buttonAlign;
|
|
136
|
+
if (buttonAlign === 'auto') {
|
|
137
|
+
// Auto-align based on waveform style
|
|
138
|
+
const style = this.options.waveformStyle;
|
|
139
|
+
if (style === 'bars') {
|
|
140
|
+
buttonAlign = 'bottom';
|
|
141
|
+
} else {
|
|
142
|
+
buttonAlign = 'center'; // blocks, mirror, line, dots, seekbar all center
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
127
146
|
// Create HTML structure
|
|
128
147
|
this.container.innerHTML = `
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
</div>
|
|
148
|
+
<div class="waveform-player-inner">
|
|
149
|
+
<div class="waveform-body">
|
|
150
|
+
<div class="waveform-track waveform-align-${buttonAlign}">
|
|
151
|
+
<button class="waveform-btn" aria-label="Play/Pause" style="
|
|
152
|
+
border-color: ${this.options.buttonColor};
|
|
153
|
+
color: ${this.options.buttonColor};
|
|
154
|
+
">
|
|
155
|
+
<span class="waveform-icon-play">${this.options.playIcon}</span>
|
|
156
|
+
<span class="waveform-icon-pause" style="display:none;">${this.options.pauseIcon}</span>
|
|
157
|
+
</button>
|
|
158
|
+
|
|
159
|
+
<div class="waveform-container">
|
|
160
|
+
<canvas></canvas>
|
|
161
|
+
<div class="waveform-markers"></div>
|
|
162
|
+
<div class="waveform-loading" style="display:none;"></div>
|
|
163
|
+
<div class="waveform-error" style="display:none;">
|
|
164
|
+
<span class="waveform-error-text">Unable to load audio</span>
|
|
147
165
|
</div>
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
|
|
169
|
+
<div class="waveform-info">
|
|
170
|
+
${this.options.artwork ? `
|
|
171
|
+
<img class="waveform-artwork" src="${this.options.artwork}" alt="Album artwork" style="
|
|
172
|
+
width: 40px;
|
|
173
|
+
height: 40px;
|
|
174
|
+
border-radius: 4px;
|
|
175
|
+
object-fit: cover;
|
|
176
|
+
flex-shrink: 0;
|
|
177
|
+
">
|
|
178
|
+
` : ''}
|
|
179
|
+
<div class="waveform-text">
|
|
180
|
+
<span class="waveform-title" style="color: ${this.options.textColor};"></span>
|
|
181
|
+
${this.options.subtitle ? `<span class="waveform-subtitle" style="color: ${this.options.textSecondaryColor};">${this.options.subtitle}</span>` : ''}
|
|
182
|
+
</div>
|
|
183
|
+
<div style="display: flex; align-items: center; gap: 1rem;">
|
|
184
|
+
${this.options.showBPM ? `
|
|
185
|
+
<span class="waveform-bpm" style="color: ${this.options.textSecondaryColor}; display: none;">
|
|
186
|
+
<span class="bpm-value">--</span> BPM
|
|
187
|
+
</span>
|
|
188
|
+
` : ''}
|
|
189
|
+
${this.options.showPlaybackSpeed ? `
|
|
190
|
+
<div class="waveform-speed">
|
|
191
|
+
<button class="speed-btn" aria-label="Playback speed">
|
|
192
|
+
<span class="speed-value">1x</span>
|
|
193
|
+
</button>
|
|
194
|
+
<div class="speed-menu" style="display: none;">
|
|
195
|
+
${this.options.playbackRates.map(rate =>
|
|
196
|
+
`<button class="speed-option" data-rate="${rate}">${rate}x</button>`
|
|
197
|
+
).join('')}
|
|
198
|
+
</div>
|
|
165
199
|
</div>
|
|
166
|
-
|
|
200
|
+
` : ''}
|
|
201
|
+
${this.options.showTime ? `
|
|
202
|
+
<span class="waveform-time" style="color: ${this.options.textSecondaryColor};">
|
|
203
|
+
<span class="time-current">0:00</span> / <span class="time-total">0:00</span>
|
|
204
|
+
</span>
|
|
205
|
+
` : ''}
|
|
167
206
|
</div>
|
|
168
207
|
</div>
|
|
169
|
-
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
210
|
+
`;
|
|
170
211
|
|
|
171
212
|
// Get references
|
|
172
213
|
this.playBtn = this.container.querySelector('.waveform-btn');
|
|
@@ -174,12 +215,16 @@ export class WaveformPlayer {
|
|
|
174
215
|
this.ctx = this.canvas.getContext('2d');
|
|
175
216
|
this.titleEl = this.container.querySelector('.waveform-title');
|
|
176
217
|
this.subtitleEl = this.container.querySelector('.waveform-subtitle');
|
|
218
|
+
this.artworkEl = this.container.querySelector('.waveform-artwork');
|
|
177
219
|
this.currentTimeEl = this.container.querySelector('.time-current');
|
|
178
220
|
this.totalTimeEl = this.container.querySelector('.time-total');
|
|
179
221
|
this.bpmEl = this.container.querySelector('.waveform-bpm');
|
|
180
222
|
this.bpmValueEl = this.container.querySelector('.bpm-value');
|
|
181
223
|
this.loadingEl = this.container.querySelector('.waveform-loading');
|
|
182
224
|
this.errorEl = this.container.querySelector('.waveform-error');
|
|
225
|
+
this.markersContainer = this.container.querySelector('.waveform-markers');
|
|
226
|
+
this.speedBtn = this.container.querySelector('.speed-btn');
|
|
227
|
+
this.speedMenu = this.container.querySelector('.speed-menu');
|
|
183
228
|
|
|
184
229
|
// Set canvas size
|
|
185
230
|
this.resizeCanvas();
|
|
@@ -191,10 +236,155 @@ export class WaveformPlayer {
|
|
|
191
236
|
*/
|
|
192
237
|
createAudio() {
|
|
193
238
|
this.audio = new Audio();
|
|
194
|
-
this.audio.preload = 'metadata';
|
|
239
|
+
this.audio.preload = this.options.preload || 'metadata';
|
|
195
240
|
this.audio.crossOrigin = 'anonymous';
|
|
196
241
|
}
|
|
197
242
|
|
|
243
|
+
// ============================================
|
|
244
|
+
// Feature Initialization
|
|
245
|
+
// ============================================
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Initialize playback speed controls
|
|
249
|
+
* @private
|
|
250
|
+
*/
|
|
251
|
+
initPlaybackSpeed() {
|
|
252
|
+
// Set initial playback rate if specified
|
|
253
|
+
if (this.options.playbackRate && this.options.playbackRate !== 1) {
|
|
254
|
+
this.audio.playbackRate = this.options.playbackRate;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Initialize speed control UI if enabled
|
|
258
|
+
if (this.options.showPlaybackSpeed) {
|
|
259
|
+
this.initSpeedControls();
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Initialize speed control UI
|
|
265
|
+
* @private
|
|
266
|
+
*/
|
|
267
|
+
initSpeedControls() {
|
|
268
|
+
const speedBtn = this.container.querySelector('.speed-btn');
|
|
269
|
+
const speedMenu = this.container.querySelector('.speed-menu');
|
|
270
|
+
|
|
271
|
+
if (!speedBtn || !speedMenu) return;
|
|
272
|
+
|
|
273
|
+
// Toggle menu
|
|
274
|
+
speedBtn.addEventListener('click', (e) => {
|
|
275
|
+
e.stopPropagation();
|
|
276
|
+
speedMenu.style.display = speedMenu.style.display === 'none' ? 'block' : 'none';
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// Close menu when clicking outside
|
|
280
|
+
document.addEventListener('click', () => {
|
|
281
|
+
speedMenu.style.display = 'none';
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// Handle speed selection
|
|
285
|
+
speedMenu.addEventListener('click', (e) => {
|
|
286
|
+
e.stopPropagation();
|
|
287
|
+
if (e.target.classList.contains('speed-option')) {
|
|
288
|
+
const rate = parseFloat(e.target.dataset.rate);
|
|
289
|
+
this.setPlaybackRate(rate);
|
|
290
|
+
speedMenu.style.display = 'none';
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// Set initial UI state
|
|
295
|
+
this.updateSpeedUI();
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Initialize keyboard controls
|
|
300
|
+
* @private
|
|
301
|
+
*/
|
|
302
|
+
initKeyboardControls() {
|
|
303
|
+
// Make container focusable but not in tab order by default
|
|
304
|
+
this.container.setAttribute('tabindex', '-1');
|
|
305
|
+
|
|
306
|
+
// Only activate keyboard controls when explicitly focused (clicked)
|
|
307
|
+
this.container.addEventListener('click', () => {
|
|
308
|
+
// Remove focus from all other players
|
|
309
|
+
WaveformPlayer.getAllInstances().forEach(player => {
|
|
310
|
+
if (player !== this) {
|
|
311
|
+
player.container.setAttribute('tabindex', '-1');
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
// Make this one focusable
|
|
315
|
+
this.container.setAttribute('tabindex', '0');
|
|
316
|
+
this.container.focus();
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
// Keyboard events
|
|
320
|
+
this.container.addEventListener('keydown', (e) => {
|
|
321
|
+
if (document.activeElement !== this.container) return;
|
|
322
|
+
|
|
323
|
+
const key = e.key;
|
|
324
|
+
const currentTime = this.audio.currentTime;
|
|
325
|
+
|
|
326
|
+
// Handle number keys 0-9 for seeking
|
|
327
|
+
if (key >= '0' && key <= '9') {
|
|
328
|
+
e.preventDefault();
|
|
329
|
+
this.seekToPercent(parseInt(key) / 10);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Handle other keys
|
|
334
|
+
const actions = {
|
|
335
|
+
' ': () => this.togglePlay(),
|
|
336
|
+
'ArrowLeft': () => this.seekTo(Math.max(0, currentTime - 5)),
|
|
337
|
+
'ArrowRight': () => this.seekTo(Math.min(this.audio.duration, currentTime + 5)),
|
|
338
|
+
'ArrowUp': () => this.setVolume(Math.min(1, this.audio.volume + 0.1)),
|
|
339
|
+
'ArrowDown': () => this.setVolume(Math.max(0, this.audio.volume - 0.1)),
|
|
340
|
+
'm': () => this.audio.muted = !this.audio.muted,
|
|
341
|
+
'M': () => this.audio.muted = !this.audio.muted
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
if (actions[key]) {
|
|
345
|
+
e.preventDefault();
|
|
346
|
+
actions[key]();
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Initialize Media Session API for system media controls
|
|
353
|
+
* @private
|
|
354
|
+
*/
|
|
355
|
+
initMediaSession() {
|
|
356
|
+
if (!('mediaSession' in navigator) || !this.options.enableMediaSession) return;
|
|
357
|
+
|
|
358
|
+
// Set metadata
|
|
359
|
+
navigator.mediaSession.metadata = new MediaMetadata({
|
|
360
|
+
title: this.options.title || 'Unknown Track',
|
|
361
|
+
artist: this.options.subtitle || '',
|
|
362
|
+
album: this.options.album || '',
|
|
363
|
+
artwork: this.options.artwork ? [
|
|
364
|
+
{src: this.options.artwork, sizes: '512x512', type: 'image/jpeg'}
|
|
365
|
+
] : []
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
// Set up action handlers
|
|
369
|
+
navigator.mediaSession.setActionHandler('play', () => this.play());
|
|
370
|
+
navigator.mediaSession.setActionHandler('pause', () => this.pause());
|
|
371
|
+
navigator.mediaSession.setActionHandler('seekbackward', () => {
|
|
372
|
+
this.seekTo(Math.max(0, this.audio.currentTime - 10));
|
|
373
|
+
});
|
|
374
|
+
navigator.mediaSession.setActionHandler('seekforward', () => {
|
|
375
|
+
this.seekTo(Math.min(this.audio.duration, this.audio.currentTime + 10));
|
|
376
|
+
});
|
|
377
|
+
navigator.mediaSession.setActionHandler('seekto', (details) => {
|
|
378
|
+
if (details.seekTime !== null) {
|
|
379
|
+
this.seekTo(details.seekTime);
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// ============================================
|
|
385
|
+
// Event Binding
|
|
386
|
+
// ============================================
|
|
387
|
+
|
|
198
388
|
/**
|
|
199
389
|
* Bind event listeners
|
|
200
390
|
* @private
|
|
@@ -235,6 +425,10 @@ export class WaveformPlayer {
|
|
|
235
425
|
}
|
|
236
426
|
}
|
|
237
427
|
|
|
428
|
+
// ============================================
|
|
429
|
+
// Audio Loading
|
|
430
|
+
// ============================================
|
|
431
|
+
|
|
238
432
|
/**
|
|
239
433
|
* Load audio file
|
|
240
434
|
* @param {string} url - Audio URL
|
|
@@ -292,6 +486,8 @@ export class WaveformPlayer {
|
|
|
292
486
|
}
|
|
293
487
|
|
|
294
488
|
this.drawWaveform();
|
|
489
|
+
this.renderMarkers();
|
|
490
|
+
this.initMediaSession();
|
|
295
491
|
|
|
296
492
|
// Fire callback
|
|
297
493
|
if (this.options.onLoad) {
|
|
@@ -305,6 +501,84 @@ export class WaveformPlayer {
|
|
|
305
501
|
}
|
|
306
502
|
}
|
|
307
503
|
|
|
504
|
+
/**
|
|
505
|
+
* Load a new track
|
|
506
|
+
* @param {string} url - Audio URL
|
|
507
|
+
* @param {string} [title] - Track title
|
|
508
|
+
* @param {string} [subtitle] - Track subtitle
|
|
509
|
+
* @param {Object} [options] - Additional options
|
|
510
|
+
* @returns {Promise<void>}
|
|
511
|
+
*/
|
|
512
|
+
async loadTrack(url, title = null, subtitle = null, options = {}) {
|
|
513
|
+
// Stop current playback and clear state
|
|
514
|
+
if (this.isPlaying) {
|
|
515
|
+
this.pause();
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Reset audio element completely
|
|
519
|
+
this.audio.src = '';
|
|
520
|
+
this.audio.load();
|
|
521
|
+
|
|
522
|
+
// Clear any errors
|
|
523
|
+
this.hasError = false;
|
|
524
|
+
if (this.errorEl) {
|
|
525
|
+
this.errorEl.style.display = 'none';
|
|
526
|
+
}
|
|
527
|
+
if (this.canvas) {
|
|
528
|
+
this.canvas.style.opacity = '1';
|
|
529
|
+
}
|
|
530
|
+
if (this.playBtn) {
|
|
531
|
+
this.playBtn.disabled = false;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Reset state
|
|
535
|
+
this.progress = 0;
|
|
536
|
+
this.waveformData = [];
|
|
537
|
+
|
|
538
|
+
// Update options (including preload if specified)
|
|
539
|
+
this.options = mergeOptions(this.options, {
|
|
540
|
+
url,
|
|
541
|
+
title: title || this.options.title,
|
|
542
|
+
subtitle: subtitle || this.options.subtitle,
|
|
543
|
+
...options
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
// Apply preload setting if it was changed
|
|
547
|
+
if (options.preload) {
|
|
548
|
+
this.audio.preload = options.preload;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Update UI elements
|
|
552
|
+
if (this.subtitleEl) {
|
|
553
|
+
if (subtitle) {
|
|
554
|
+
this.subtitleEl.textContent = subtitle;
|
|
555
|
+
this.subtitleEl.style.display = '';
|
|
556
|
+
} else if (subtitle === '') {
|
|
557
|
+
this.subtitleEl.style.display = 'none';
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// Update artwork if provided
|
|
562
|
+
if (options.artwork && this.artworkEl) {
|
|
563
|
+
this.artworkEl.src = options.artwork;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// Clear markers if new markers provided
|
|
567
|
+
if (options.markers) {
|
|
568
|
+
this.options.markers = options.markers;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// Load the new track
|
|
572
|
+
await this.load(url);
|
|
573
|
+
|
|
574
|
+
// Auto-play the new track
|
|
575
|
+
this.play();
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// ============================================
|
|
579
|
+
// Visualization
|
|
580
|
+
// ============================================
|
|
581
|
+
|
|
308
582
|
/**
|
|
309
583
|
* Set waveform data
|
|
310
584
|
* @private
|
|
@@ -354,6 +628,55 @@ export class WaveformPlayer {
|
|
|
354
628
|
this.drawWaveform();
|
|
355
629
|
}
|
|
356
630
|
|
|
631
|
+
/**
|
|
632
|
+
* Render markers on the waveform
|
|
633
|
+
* @private
|
|
634
|
+
*/
|
|
635
|
+
renderMarkers() {
|
|
636
|
+
if (!this.options.showMarkers || !this.options.markers?.length || !this.markersContainer) return;
|
|
637
|
+
|
|
638
|
+
// Clear existing markers
|
|
639
|
+
this.markersContainer.innerHTML = '';
|
|
640
|
+
|
|
641
|
+
// Don't render if audio duration isn't available yet
|
|
642
|
+
if (!this.audio || !this.audio.duration || this.audio.duration === 0) {
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// Add each marker
|
|
647
|
+
this.options.markers.forEach((marker, index) => {
|
|
648
|
+
const position = (marker.time / this.audio.duration) * 100;
|
|
649
|
+
|
|
650
|
+
const markerEl = document.createElement('button');
|
|
651
|
+
markerEl.className = 'waveform-marker';
|
|
652
|
+
markerEl.style.left = `${position}%`;
|
|
653
|
+
markerEl.style.backgroundColor = marker.color || 'rgba(255, 255, 255, 0.5)';
|
|
654
|
+
markerEl.setAttribute('aria-label', marker.label);
|
|
655
|
+
markerEl.setAttribute('data-time', marker.time);
|
|
656
|
+
|
|
657
|
+
// Tooltip
|
|
658
|
+
const tooltip = document.createElement('span');
|
|
659
|
+
tooltip.className = 'waveform-marker-tooltip';
|
|
660
|
+
tooltip.textContent = marker.label;
|
|
661
|
+
markerEl.appendChild(tooltip);
|
|
662
|
+
|
|
663
|
+
// Click to seek
|
|
664
|
+
markerEl.addEventListener('click', (e) => {
|
|
665
|
+
e.stopPropagation();
|
|
666
|
+
this.seekTo(marker.time);
|
|
667
|
+
if (this.options.playOnSeek && !this.isPlaying) {
|
|
668
|
+
this.play();
|
|
669
|
+
}
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
this.markersContainer.appendChild(markerEl);
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// ============================================
|
|
677
|
+
// Event Handlers
|
|
678
|
+
// ============================================
|
|
679
|
+
|
|
357
680
|
/**
|
|
358
681
|
* Handle canvas click
|
|
359
682
|
* @private
|
|
@@ -387,6 +710,8 @@ export class WaveformPlayer {
|
|
|
387
710
|
if (this.totalTimeEl) {
|
|
388
711
|
this.totalTimeEl.textContent = formatTime(this.audio.duration);
|
|
389
712
|
}
|
|
713
|
+
// Re-render markers when duration is known
|
|
714
|
+
this.renderMarkers();
|
|
390
715
|
}
|
|
391
716
|
|
|
392
717
|
/**
|
|
@@ -476,6 +801,10 @@ export class WaveformPlayer {
|
|
|
476
801
|
}
|
|
477
802
|
}
|
|
478
803
|
|
|
804
|
+
// ============================================
|
|
805
|
+
// Progress Updates
|
|
806
|
+
// ============================================
|
|
807
|
+
|
|
479
808
|
/**
|
|
480
809
|
* Start smooth update animation
|
|
481
810
|
* @private
|
|
@@ -527,6 +856,10 @@ export class WaveformPlayer {
|
|
|
527
856
|
}
|
|
528
857
|
}
|
|
529
858
|
|
|
859
|
+
// ============================================
|
|
860
|
+
// UI Updates
|
|
861
|
+
// ============================================
|
|
862
|
+
|
|
530
863
|
/**
|
|
531
864
|
* Update BPM display
|
|
532
865
|
* @private
|
|
@@ -538,6 +871,23 @@ export class WaveformPlayer {
|
|
|
538
871
|
}
|
|
539
872
|
}
|
|
540
873
|
|
|
874
|
+
/**
|
|
875
|
+
* Update speed UI to reflect current rate
|
|
876
|
+
* @private
|
|
877
|
+
*/
|
|
878
|
+
updateSpeedUI() {
|
|
879
|
+
const speedValue = this.container.querySelector('.speed-value');
|
|
880
|
+
if (speedValue) {
|
|
881
|
+
const rate = this.audio.playbackRate;
|
|
882
|
+
speedValue.textContent = rate === 1 ? '1x' : `${rate}x`;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// Update active state in menu
|
|
886
|
+
this.container.querySelectorAll('.speed-option').forEach(btn => {
|
|
887
|
+
btn.classList.toggle('active', parseFloat(btn.dataset.rate) === this.audio.playbackRate);
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
|
|
541
891
|
// ============================================
|
|
542
892
|
// Public API
|
|
543
893
|
// ============================================
|
|
@@ -576,6 +926,17 @@ export class WaveformPlayer {
|
|
|
576
926
|
}
|
|
577
927
|
}
|
|
578
928
|
|
|
929
|
+
/**
|
|
930
|
+
* Seek to time in seconds
|
|
931
|
+
* @param {number} seconds - Time in seconds
|
|
932
|
+
*/
|
|
933
|
+
seekTo(seconds) {
|
|
934
|
+
if (this.audio && this.audio.duration) {
|
|
935
|
+
this.audio.currentTime = Math.max(0, Math.min(seconds, this.audio.duration));
|
|
936
|
+
this.updateProgress();
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
579
940
|
/**
|
|
580
941
|
* Seek to percentage
|
|
581
942
|
* @param {number} percent - Percentage (0-1)
|
|
@@ -597,6 +958,20 @@ export class WaveformPlayer {
|
|
|
597
958
|
}
|
|
598
959
|
}
|
|
599
960
|
|
|
961
|
+
/**
|
|
962
|
+
* Set playback rate
|
|
963
|
+
* @param {number} rate - Playback rate (0.5 to 2)
|
|
964
|
+
*/
|
|
965
|
+
setPlaybackRate(rate) {
|
|
966
|
+
if (!this.audio) return;
|
|
967
|
+
|
|
968
|
+
const clampedRate = Math.max(0.5, Math.min(2, rate));
|
|
969
|
+
this.audio.playbackRate = clampedRate;
|
|
970
|
+
this.options.playbackRate = clampedRate;
|
|
971
|
+
|
|
972
|
+
this.updateSpeedUI();
|
|
973
|
+
}
|
|
974
|
+
|
|
600
975
|
/**
|
|
601
976
|
* Destroy player instance
|
|
602
977
|
*/
|
|
@@ -618,7 +993,7 @@ export class WaveformPlayer {
|
|
|
618
993
|
}
|
|
619
994
|
|
|
620
995
|
// ============================================
|
|
621
|
-
// Static
|
|
996
|
+
// Static Methods
|
|
622
997
|
// ============================================
|
|
623
998
|
|
|
624
999
|
/**
|
|
@@ -676,5 +1051,4 @@ export class WaveformPlayer {
|
|
|
676
1051
|
throw error;
|
|
677
1052
|
}
|
|
678
1053
|
}
|
|
679
|
-
|
|
680
1054
|
}
|
package/src/js/themes.js
CHANGED
|
@@ -37,6 +37,15 @@ 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
|
+
// Layout Options
|
|
48
|
+
buttonAlign: 'auto', // 'auto', 'top', 'center', 'bottom'
|
|
40
49
|
|
|
41
50
|
// Default waveform style
|
|
42
51
|
waveformStyle: 'mirror',
|
|
@@ -63,10 +72,17 @@ export const DEFAULT_OPTIONS = {
|
|
|
63
72
|
showBPM: false,
|
|
64
73
|
singlePlay: true,
|
|
65
74
|
playOnSeek: true,
|
|
75
|
+
enableMediaSession: true,
|
|
76
|
+
|
|
77
|
+
// Markers
|
|
78
|
+
markers: [],
|
|
79
|
+
showMarkers: true,
|
|
66
80
|
|
|
67
81
|
// Content
|
|
68
82
|
title: null,
|
|
69
83
|
subtitle: null,
|
|
84
|
+
artwork: null,
|
|
85
|
+
album: '',
|
|
70
86
|
|
|
71
87
|
// Icons (SVG)
|
|
72
88
|
playIcon: '<svg viewBox="0 0 24 24" width="16" height="16"><path d="M8 5v14l11-7z"/></svg>',
|
package/src/js/utils.js
CHANGED
|
@@ -15,11 +15,15 @@ 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;
|
|
21
24
|
if (element.dataset.barWidth) options.barWidth = parseInt(element.dataset.barWidth);
|
|
22
25
|
if (element.dataset.barSpacing) options.barSpacing = parseInt(element.dataset.barSpacing);
|
|
26
|
+
if (element.dataset.buttonAlign) options.buttonAlign = element.dataset.buttonAlign;
|
|
23
27
|
|
|
24
28
|
// Color preset
|
|
25
29
|
if (element.dataset.colorPreset) options.colorPreset = element.dataset.colorPreset;
|
|
@@ -46,13 +50,44 @@ export function parseDataAttributes(element) {
|
|
|
46
50
|
if (element.dataset.singlePlay) options.singlePlay = element.dataset.singlePlay === 'true';
|
|
47
51
|
if (element.dataset.playOnSeek) options.playOnSeek = element.dataset.playOnSeek === 'true';
|
|
48
52
|
|
|
49
|
-
// Content
|
|
53
|
+
// Content and metadata
|
|
50
54
|
if (element.dataset.title) options.title = element.dataset.title;
|
|
51
55
|
if (element.dataset.subtitle) options.subtitle = element.dataset.subtitle;
|
|
56
|
+
if (element.dataset.album) options.album = element.dataset.album;
|
|
57
|
+
if (element.dataset.artwork) options.artwork = element.dataset.artwork;
|
|
52
58
|
|
|
53
59
|
// Waveform data
|
|
54
60
|
if (element.dataset.waveform) options.waveform = element.dataset.waveform;
|
|
55
61
|
|
|
62
|
+
// Markers
|
|
63
|
+
if (element.dataset.markers) {
|
|
64
|
+
try {
|
|
65
|
+
options.markers = JSON.parse(element.dataset.markers);
|
|
66
|
+
} catch (e) {
|
|
67
|
+
console.warn('Invalid markers JSON:', e);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Playback controls
|
|
72
|
+
if (element.dataset.playbackRate) {
|
|
73
|
+
options.playbackRate = parseFloat(element.dataset.playbackRate);
|
|
74
|
+
}
|
|
75
|
+
if (element.dataset.showPlaybackSpeed !== undefined) {
|
|
76
|
+
options.showPlaybackSpeed = element.dataset.showPlaybackSpeed === 'true';
|
|
77
|
+
}
|
|
78
|
+
if (element.dataset.playbackRates) {
|
|
79
|
+
try {
|
|
80
|
+
options.playbackRates = JSON.parse(element.dataset.playbackRates);
|
|
81
|
+
} catch (e) {
|
|
82
|
+
console.warn('Invalid playbackRates JSON:', e);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Media Session API
|
|
87
|
+
if (element.dataset.enableMediaSession !== undefined) {
|
|
88
|
+
options.enableMediaSession = element.dataset.enableMediaSession === 'true';
|
|
89
|
+
}
|
|
90
|
+
|
|
56
91
|
return options;
|
|
57
92
|
}
|
|
58
93
|
|