@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/dist/waveform-player.css +1 -1
- package/dist/waveform-player.esm.js +58 -38
- package/dist/waveform-player.js +377 -54
- package/dist/waveform-player.min.js +58 -38
- package/package.json +2 -2
- package/src/css/waveform-player.css +176 -10
- package/src/js/audio.js +37 -19
- package/src/js/core.js +401 -40
- package/src/js/themes.js +15 -0
- package/src/js/utils.js +35 -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
|
|
|
@@ -126,47 +132,69 @@ export class WaveformPlayer {
|
|
|
126
132
|
|
|
127
133
|
// Create HTML structure
|
|
128
134
|
this.container.innerHTML = `
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|