@arraypress/waveform-bar 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/js/core.js ADDED
@@ -0,0 +1,1271 @@
1
+ /**
2
+ * @module core
3
+ * @description Main WaveformBar class
4
+ */
5
+
6
+ import {ICONS} from './icons.js';
7
+ import {extractTitle, escapeHtml, formatTime, parseTrackFromElement} from './utils.js';
8
+ import {saveQueueState, restoreQueueState, saveVolume, restoreVolume, saveFavorites, restoreFavorites} from './storage.js';
9
+ import {fireAction} from './actions.js';
10
+ import {buildBarHTML} from './dom.js';
11
+ import {createQueuePanel, renderQueue} from './queue.js';
12
+
13
+ /**
14
+ * Default configuration
15
+ */
16
+ const DEFAULTS = {
17
+ persist: true,
18
+ autoResume: true,
19
+ continuous: true,
20
+ repeat: 'off', // 'off', 'all', 'one'
21
+ showRepeat: true,
22
+ showQueue: true,
23
+ showPrevNext: true,
24
+ showVolume: true,
25
+ showMute: true,
26
+ showMeta: true,
27
+ showTime: true,
28
+ showTrackLink: true,
29
+ maxMeta: 3,
30
+ defaultArtwork: null, // URL to fallback artwork image
31
+ theme: null, // 'dark', 'light', or null (dark by default)
32
+ waveformStyle: 'mirror',
33
+ waveformHeight: 32,
34
+ barWidth: 2,
35
+ barSpacing: 0,
36
+ waveformColor: null,
37
+ progressColor: null,
38
+ markerColor: 'rgba(255, 255, 255, 0.25)',
39
+ volume: 1,
40
+ storageKey: 'waveform-bar',
41
+ actions: null,
42
+ onPlay: null,
43
+ onPause: null,
44
+ onTrackChange: null,
45
+ onQueueChange: null,
46
+ onVolumeChange: null,
47
+ onFavorite: null,
48
+ onCart: null
49
+ };
50
+
51
+ export class WaveformBar {
52
+ constructor() {
53
+ this.config = null;
54
+ this.player = null;
55
+ this.queue = [];
56
+ this.currentIndex = -1;
57
+ this.isPlaying = false;
58
+ this.isInitialized = false;
59
+ this.queueOpen = false;
60
+ this.volume = 1;
61
+ this.isMuted = false;
62
+ this._volumeBeforeMute = 1;
63
+ this._lastPosition = 0;
64
+ this._favorites = new Set();
65
+ this._cartItems = new Set();
66
+ this._observer = null;
67
+ this._activeMarkers = null;
68
+ this._currentMarkerIndex = -1;
69
+ this.repeat = 'off'; // 'off', 'all', 'one'
70
+
71
+ // DOM refs
72
+ this.barEl = null;
73
+ this.queueEl = null;
74
+ this.waveformContainer = null;
75
+ this.volumePopupEl = null;
76
+ this.titleEl = null;
77
+ this.artistEl = null;
78
+ this.metaEl = null;
79
+ this.playBtnEl = null;
80
+ this.repeatBtnEl = null;
81
+ this.queueBtnEl = null;
82
+ this.queueBodyEl = null;
83
+ this.queueCountEl = null;
84
+ this.volumeSliderEl = null;
85
+ this.muteBtnEl = null;
86
+ this.favBtnEl = null;
87
+ this.cartBtnEl = null;
88
+ this.timeCurrentEl = null;
89
+ this.timeTotalEl = null;
90
+ }
91
+
92
+ // =====================================================================
93
+ // Init / Destroy
94
+ // =====================================================================
95
+
96
+ /**
97
+ * Initialize WaveformBar
98
+ * @param {Object} [config={}]
99
+ * @returns {WaveformBar}
100
+ */
101
+ init(config = {}) {
102
+ if (this.isInitialized) this.destroy();
103
+
104
+ this.config = {...DEFAULTS, ...config};
105
+ this.volume = this.config.volume;
106
+
107
+ if (typeof window.WaveformPlayer === 'undefined') {
108
+ console.error('WaveformBar: WaveformPlayer is required.');
109
+ return this;
110
+ }
111
+
112
+ this._createBar();
113
+ this._createQueue();
114
+ this._initPlayer();
115
+ this._bindTriggers();
116
+ this._observeDOM();
117
+
118
+ if (this.config.persist) {
119
+ this._restoreVolume();
120
+ this._restoreFavorites();
121
+ }
122
+
123
+ // Seed favorites/cart from data attributes on page elements
124
+ // This is authoritative — overrides localStorage if present
125
+ this._seedFromAttributes();
126
+
127
+ if (this.config.persist) {
128
+ this._restoreState();
129
+ }
130
+
131
+ this.isInitialized = true;
132
+
133
+ // Save exact position when navigating away
134
+ this._beforeUnloadHandler = () => this._saveState();
135
+ window.addEventListener('beforeunload', this._beforeUnloadHandler);
136
+
137
+ return this;
138
+ }
139
+
140
+ /**
141
+ * Destroy everything
142
+ * @returns {WaveformBar}
143
+ */
144
+ destroy() {
145
+ if (this.player) { this.player.destroy(); this.player = null; }
146
+ if (this.barEl) { this.barEl.remove(); this.barEl = null; }
147
+ if (this.queueEl) { this.queueEl.remove(); this.queueEl = null; }
148
+ if (this._observer) { this._observer.disconnect(); this._observer = null; }
149
+ if (this._beforeUnloadHandler) { window.removeEventListener('beforeunload', this._beforeUnloadHandler); this._beforeUnloadHandler = null; }
150
+
151
+ document.querySelectorAll('[data-wb-play],[data-wb-queue]').forEach(el => delete el._wbBound);
152
+ document.querySelectorAll('.wb-current,.wb-playing').forEach(el => el.classList.remove('wb-current', 'wb-playing'));
153
+
154
+ this.queue = [];
155
+ this.currentIndex = -1;
156
+ this.isPlaying = false;
157
+ this.queueOpen = false;
158
+ this.isInitialized = false;
159
+ this.config = null;
160
+ return this;
161
+ }
162
+
163
+ // =====================================================================
164
+ // DOM Setup (private)
165
+ // =====================================================================
166
+
167
+ _createBar() {
168
+ this.barEl = document.createElement('div');
169
+ this.barEl.className = 'waveform-bar';
170
+
171
+ // Theme: explicit or auto-detect
172
+ const theme = this.config.theme || this._detectTheme();
173
+ if (theme === 'light') this.barEl.classList.add('wb-light');
174
+ this._resolvedTheme = theme;
175
+
176
+ this.barEl.id = 'waveform-bar';
177
+ this.barEl.innerHTML = buildBarHTML(this.config);
178
+ document.body.appendChild(this.barEl);
179
+
180
+ // Cache refs
181
+ this.titleEl = this.barEl.querySelector('.wb-title');
182
+ this.artistEl = this.barEl.querySelector('.wb-artist');
183
+ this.metaEl = this.barEl.querySelector('.wb-meta');
184
+ this.playBtnEl = this.barEl.querySelector('.wb-play');
185
+ this.waveformContainer = this.barEl.querySelector('.wb-waveform-container');
186
+ this.queueBtnEl = this.barEl.querySelector('.wb-queue-btn');
187
+ this.muteBtnEl = this.barEl.querySelector('.wb-mute');
188
+ this.volumeSliderEl = this.barEl.querySelector('.wb-volume-slider');
189
+ this.favBtnEl = this.barEl.querySelector('.wb-fav');
190
+ this.cartBtnEl = this.barEl.querySelector('.wb-cart');
191
+ this.timeCurrentEl = this.barEl.querySelector('.wb-time-current');
192
+ this.timeTotalEl = this.barEl.querySelector('.wb-time-total');
193
+
194
+ // Bind controls
195
+ this.playBtnEl.addEventListener('click', () => this.togglePlay());
196
+
197
+ const prevBtn = this.barEl.querySelector('.wb-prev');
198
+ const nextBtn = this.barEl.querySelector('.wb-next');
199
+ if (prevBtn) prevBtn.addEventListener('click', () => this.previous());
200
+ if (nextBtn) nextBtn.addEventListener('click', () => this.next());
201
+
202
+ this.repeatBtnEl = this.barEl.querySelector('.wb-repeat');
203
+ if (this.repeatBtnEl) {
204
+ this.repeat = this.config.repeat || 'off';
205
+ this._updateRepeatButton();
206
+ this.repeatBtnEl.addEventListener('click', () => this.cycleRepeat());
207
+ }
208
+
209
+ if (this.queueBtnEl) this.queueBtnEl.addEventListener('click', () => this.toggleQueuePanel());
210
+
211
+ // Volume: click mutes, hover shows popup
212
+ this.volumePopupEl = this.barEl.querySelector('.wb-volume-popup');
213
+ const volumeWrapper = this.barEl.querySelector('.wb-volume');
214
+
215
+ if (this.muteBtnEl) {
216
+ this.muteBtnEl.addEventListener('click', (e) => {
217
+ e.stopPropagation();
218
+ this.toggleMute();
219
+ });
220
+ }
221
+
222
+ if (volumeWrapper && this.volumePopupEl) {
223
+ let hoverTimeout;
224
+ volumeWrapper.addEventListener('mouseenter', () => {
225
+ clearTimeout(hoverTimeout);
226
+ this.openVolumePopup();
227
+ });
228
+ volumeWrapper.addEventListener('mouseleave', () => {
229
+ hoverTimeout = setTimeout(() => this.closeVolumePopup(), 300);
230
+ });
231
+ }
232
+
233
+ if (this.volumeSliderEl) {
234
+ this.volumeSliderEl.addEventListener('input', (e) => {
235
+ e.stopPropagation();
236
+ this.setVolume(parseInt(e.target.value) / 100);
237
+ });
238
+ }
239
+
240
+ // Close volume popup on outside click
241
+ document.addEventListener('click', (e) => {
242
+ if (this.volumePopupEl?.classList.contains('wb-volume-open') &&
243
+ !this.barEl.querySelector('.wb-volume')?.contains(e.target)) {
244
+ this.closeVolumePopup();
245
+ }
246
+ });
247
+
248
+ if (this.favBtnEl) this.favBtnEl.addEventListener('click', () => this.toggleFavorite());
249
+ if (this.cartBtnEl) this.cartBtnEl.addEventListener('click', () => this.addToCart());
250
+
251
+ // Track link
252
+ if (this.config.showTrackLink) {
253
+ this.barEl.querySelector('.wb-track').addEventListener('click', () => {
254
+ const t = this.getCurrentTrack();
255
+ if (t && t.link) window.location.href = t.link;
256
+ });
257
+ }
258
+ }
259
+
260
+ _createQueue() {
261
+ if (!this.config.showQueue) return;
262
+
263
+ this.queueEl = createQueuePanel();
264
+ if (this._resolvedTheme === 'light') this.queueEl.classList.add('wb-light');
265
+ document.body.appendChild(this.queueEl);
266
+
267
+ this.queueBodyEl = this.queueEl.querySelector('.wb-queue-body');
268
+ this.queueCountEl = this.queueEl.querySelector('.wb-queue-count');
269
+
270
+ this.queueEl.querySelector('.wb-queue-clear').addEventListener('click', () => this.clearQueue());
271
+
272
+ document.addEventListener('click', (e) => {
273
+ if (this.queueOpen && !this.queueEl.contains(e.target) && !this.queueBtnEl.contains(e.target)) {
274
+ this.closeQueuePanel();
275
+ }
276
+ });
277
+ }
278
+
279
+ _initPlayer() {
280
+ const opts = {
281
+ showControls: false,
282
+ showInfo: false,
283
+ waveformStyle: this.config.waveformStyle,
284
+ height: this.config.waveformHeight,
285
+ barWidth: this.config.barWidth,
286
+ barSpacing: this.config.barSpacing,
287
+ singlePlay: false,
288
+ onPlay: () => {
289
+ this.isPlaying = true;
290
+ this._updatePlayButton();
291
+ this._syncPageState();
292
+ const track = this.getCurrentTrack();
293
+ this._emit('play', {track});
294
+ if (this.config.onPlay) this.config.onPlay(track);
295
+ },
296
+ onPause: () => {
297
+ this.isPlaying = false;
298
+ this._updatePlayButton();
299
+ this._syncPageState();
300
+ this._saveState();
301
+ const track = this.getCurrentTrack();
302
+ this._emit('pause', {track});
303
+ if (this.config.onPause) this.config.onPause(track);
304
+ },
305
+ onEnd: () => {
306
+ this.isPlaying = false;
307
+ this._updatePlayButton();
308
+ this._syncPageState();
309
+
310
+ // Reset time display
311
+ if (this.timeCurrentEl) this.timeCurrentEl.textContent = '0:00';
312
+
313
+ // Handle repeat modes
314
+ if (this.repeat === 'one') {
315
+ // Repeat current track
316
+ if (this.player) {
317
+ this.player.seekTo(0);
318
+ this.player.play().catch(() => {});
319
+ }
320
+ return;
321
+ }
322
+
323
+ if (this.config.continuous && this.currentIndex < this.queue.length - 1) {
324
+ // Next track
325
+ this.currentIndex++;
326
+ this._loadCurrentTrack();
327
+ } else if (this.repeat === 'all' && this.queue.length > 0) {
328
+ // Loop back to start
329
+ this.currentIndex = 0;
330
+ this._loadCurrentTrack();
331
+ }
332
+ },
333
+ onTimeUpdate: (currentTime, duration) => {
334
+ this._lastPosition = currentTime;
335
+ if (this.timeCurrentEl) this.timeCurrentEl.textContent = formatTime(currentTime);
336
+ if (this.timeTotalEl) this.timeTotalEl.textContent = formatTime(duration);
337
+
338
+ // Save state periodically during playback
339
+ if (!this._lastSaveTime || currentTime - this._lastSaveTime > 2) {
340
+ this._lastSaveTime = currentTime;
341
+ this._saveState();
342
+ }
343
+
344
+ // DJ mode: update title/artist when crossing marker boundaries
345
+ if (this._activeMarkers) {
346
+ this._checkMarkerBoundary(currentTime);
347
+ }
348
+ },
349
+ onLoad: null
350
+ };
351
+
352
+ if (this.config.waveformColor) opts.waveformColor = this.config.waveformColor;
353
+ if (this.config.progressColor) opts.progressColor = this.config.progressColor;
354
+
355
+ this.player = new window.WaveformPlayer(this.waveformContainer, opts);
356
+ this.player.setVolume(this.volume);
357
+ }
358
+
359
+ // =====================================================================
360
+ // Triggers (private)
361
+ // =====================================================================
362
+
363
+ _bindTriggers() {
364
+ document.querySelectorAll('[data-wb-play]').forEach(el => {
365
+ if (el._wbBound) return;
366
+ el._wbBound = true;
367
+ el.addEventListener('click', (e) => {
368
+ e.preventDefault();
369
+ const track = parseTrackFromElement(el);
370
+ if (track) this.play(track);
371
+ });
372
+ });
373
+
374
+ document.querySelectorAll('[data-wb-queue]').forEach(el => {
375
+ if (el._wbBound) return;
376
+ el._wbBound = true;
377
+ el.addEventListener('click', (e) => {
378
+ e.preventDefault();
379
+ e.stopPropagation();
380
+ const track = parseTrackFromElement(el);
381
+ if (track) this.addToQueue(track);
382
+ });
383
+ });
384
+ }
385
+
386
+ _observeDOM() {
387
+ if (typeof MutationObserver === 'undefined') return;
388
+ this._observer = new MutationObserver(() => {
389
+ this._bindTriggers();
390
+ this._syncPageState();
391
+ });
392
+ this._observer.observe(document.body, {childList: true, subtree: true});
393
+ }
394
+
395
+ // =====================================================================
396
+ // Playback (public)
397
+ // =====================================================================
398
+
399
+ /**
400
+ * Play a track immediately
401
+ * @param {Object|string} trackOrUrl
402
+ * @returns {WaveformBar}
403
+ */
404
+ play(trackOrUrl) {
405
+ const track = typeof trackOrUrl === 'string'
406
+ ? {url: trackOrUrl, id: trackOrUrl, title: extractTitle(trackOrUrl)}
407
+ : trackOrUrl;
408
+
409
+ if (!track || !track.url) return this;
410
+
411
+ const current = this.getCurrentTrack();
412
+ if (current && current.url === track.url) {
413
+ this.togglePlay();
414
+ return this;
415
+ }
416
+
417
+ const existing = this.queue.findIndex(t => t.url === track.url);
418
+ if (existing >= 0) {
419
+ // Merge new track data into existing queue entry
420
+ // so markers, waveform, and other properties get updated
421
+ this.queue[existing] = {...this.queue[existing], ...track};
422
+ this.currentIndex = existing;
423
+ } else {
424
+ const insertAt = this.currentIndex + 1;
425
+ this.queue.splice(insertAt, 0, track);
426
+ this.currentIndex = insertAt;
427
+ }
428
+
429
+ this._loadCurrentTrack();
430
+ return this;
431
+ }
432
+
433
+ /**
434
+ * Add to end of queue
435
+ * @param {Object|string} trackOrUrl
436
+ * @returns {WaveformBar}
437
+ */
438
+ addToQueue(trackOrUrl) {
439
+ const track = typeof trackOrUrl === 'string'
440
+ ? {url: trackOrUrl, id: trackOrUrl, title: extractTitle(trackOrUrl)}
441
+ : trackOrUrl;
442
+
443
+ if (!track || !track.url) return this;
444
+ if (this.queue.find(t => t.url === track.url)) return this;
445
+
446
+ this.queue.push(track);
447
+ this._renderQueue();
448
+ this._saveState();
449
+ this._updateNavButtons();
450
+
451
+ if (this.currentIndex === -1) {
452
+ this.currentIndex = 0;
453
+ this._loadCurrentTrack();
454
+ }
455
+
456
+ if (this.config.onQueueChange) this.config.onQueueChange(this.queue, this.currentIndex);
457
+ return this;
458
+ }
459
+
460
+ togglePlay() {
461
+ if (!this.player) return this;
462
+ this.isPlaying ? this.player.pause() : this.player.play();
463
+ return this;
464
+ }
465
+
466
+ pause() {
467
+ if (this.player && this.isPlaying) this.player.pause();
468
+ return this;
469
+ }
470
+
471
+ next() {
472
+ if (this.currentIndex < this.queue.length - 1) {
473
+ this.currentIndex++;
474
+ this._loadCurrentTrack();
475
+ } else if (this.repeat === 'all' && this.queue.length > 0) {
476
+ this.currentIndex = 0;
477
+ this._loadCurrentTrack();
478
+ }
479
+ return this;
480
+ }
481
+
482
+ previous() {
483
+ if (this.player && this.player.audio && this.player.audio.currentTime > 3) {
484
+ this.player.seekTo(0);
485
+ return this;
486
+ }
487
+ if (this.currentIndex > 0) {
488
+ this.currentIndex--;
489
+ this._loadCurrentTrack();
490
+ } else if (this.repeat === 'all' && this.queue.length > 0) {
491
+ this.currentIndex = this.queue.length - 1;
492
+ this._loadCurrentTrack();
493
+ }
494
+ return this;
495
+ }
496
+
497
+ skipTo(index) {
498
+ if (index < 0 || index >= this.queue.length) return this;
499
+ if (index === this.currentIndex) {
500
+ this.togglePlay();
501
+ return this;
502
+ }
503
+ this.currentIndex = index;
504
+ this._loadCurrentTrack();
505
+ return this;
506
+ }
507
+
508
+ /**
509
+ * Seek to a specific marker by index on the current track
510
+ * @param {number} markerIndex
511
+ * @returns {WaveformBar}
512
+ */
513
+ seekToMarker(markerIndex) {
514
+ if (!this._activeMarkers || markerIndex < 0 || markerIndex >= this._activeMarkers.length) return this;
515
+ const marker = this._activeMarkers[markerIndex];
516
+ if (this.player) {
517
+ this.player.seekTo(marker.time);
518
+ if (!this.isPlaying) this.togglePlay();
519
+ }
520
+ return this;
521
+ }
522
+
523
+ /**
524
+ * Seek to a marker by label on the current track
525
+ * @param {string} label
526
+ * @returns {WaveformBar}
527
+ */
528
+ seekToMarkerByLabel(label) {
529
+ if (!this._activeMarkers) return this;
530
+ const index = this._activeMarkers.findIndex(m =>
531
+ (m.label || m.title || '').toLowerCase() === label.toLowerCase()
532
+ );
533
+ if (index >= 0) this.seekToMarker(index);
534
+ return this;
535
+ }
536
+
537
+ // =====================================================================
538
+ // Volume (public)
539
+ // =====================================================================
540
+
541
+ setVolume(level) {
542
+ this.volume = Math.max(0, Math.min(1, level));
543
+ this.isMuted = this.volume === 0;
544
+ if (this.player) this.player.setVolume(this.volume);
545
+ this._updateVolumeUI();
546
+ saveVolume(this.config.storageKey, this.volume, this.isMuted, this._volumeBeforeMute);
547
+ this._emit('volumechange', {volume: this.volume});
548
+ if (this.config.onVolumeChange) this.config.onVolumeChange(this.volume);
549
+ return this;
550
+ }
551
+
552
+ getVolume() { return this.volume; }
553
+
554
+ toggleMute() {
555
+ if (this.isMuted) {
556
+ this.setVolume(this._volumeBeforeMute || 1);
557
+ } else {
558
+ this._volumeBeforeMute = this.volume;
559
+ this.isMuted = true;
560
+ if (this.player) this.player.setVolume(0);
561
+ this._updateVolumeUI();
562
+ }
563
+ return this;
564
+ }
565
+
566
+ // =====================================================================
567
+ // Actions (public)
568
+ // =====================================================================
569
+
570
+ toggleFavorite() {
571
+ const track = this.getCurrentTrack();
572
+ if (!track) return this;
573
+
574
+ const id = track.id || track.url;
575
+ const wasFav = this._favorites.has(id);
576
+
577
+ if (wasFav) {
578
+ this._favorites.delete(id);
579
+ } else {
580
+ this._favorites.add(id);
581
+ }
582
+
583
+ this._updateFavoriteUI();
584
+ this._syncFavoriteAttributes(track.url, !wasFav);
585
+ saveFavorites(this.config.storageKey, this._favorites);
586
+
587
+ this._emit('favorite', {track, favorited: !wasFav});
588
+ if (this.config.onFavorite) this.config.onFavorite(track, !wasFav);
589
+
590
+ if (this.config.actions?.favorite) {
591
+ fireAction(this.config.actions.favorite, {
592
+ action: 'favorite', id, url: track.url, title: track.title, favorited: !wasFav
593
+ });
594
+ }
595
+
596
+ return this;
597
+ }
598
+
599
+ addToCart() {
600
+ const track = this.getCurrentTrack();
601
+ if (!track) return this;
602
+
603
+ const id = track.id || track.url;
604
+ this._cartItems.add(id);
605
+
606
+ // Visual feedback on bar button
607
+ if (this.cartBtnEl) {
608
+ this.cartBtnEl.classList.add('wb-action-done');
609
+ setTimeout(() => this.cartBtnEl.classList.remove('wb-action-done'), 1500);
610
+ }
611
+
612
+ // Sync data attribute back to page triggers
613
+ this._syncCartAttributes(track.url, true);
614
+
615
+ this._emit('cart', {track});
616
+ if (this.config.onCart) this.config.onCart(track);
617
+
618
+ if (this.config.actions?.cart) {
619
+ fireAction(this.config.actions.cart, {
620
+ action: 'cart', id, url: track.url, title: track.title
621
+ });
622
+ }
623
+
624
+ return this;
625
+ }
626
+
627
+ isFavorited(id) {
628
+ if (!id) {
629
+ const t = this.getCurrentTrack();
630
+ id = t ? (t.id || t.url) : null;
631
+ }
632
+ return id ? this._favorites.has(id) : false;
633
+ }
634
+
635
+ isInCart(id) {
636
+ if (!id) {
637
+ const t = this.getCurrentTrack();
638
+ id = t ? (t.id || t.url) : null;
639
+ }
640
+ return id ? this._cartItems.has(id) : false;
641
+ }
642
+
643
+ // =====================================================================
644
+ // Queue (public)
645
+ // =====================================================================
646
+
647
+ removeFromQueue(index) {
648
+ if (index < 0 || index >= this.queue.length || index === this.currentIndex) return this;
649
+ this.queue.splice(index, 1);
650
+ if (index < this.currentIndex) this.currentIndex--;
651
+ this._renderQueue();
652
+ this._saveState();
653
+ this._updateNavButtons();
654
+ this._emit('queuechange', {queue: this.queue, currentIndex: this.currentIndex});
655
+ if (this.config.onQueueChange) this.config.onQueueChange(this.queue, this.currentIndex);
656
+ return this;
657
+ }
658
+
659
+ clearQueue() {
660
+ const current = this.getCurrentTrack();
661
+ this.queue = current ? [current] : [];
662
+ this.currentIndex = current ? 0 : -1;
663
+ this._renderQueue();
664
+ this._saveState();
665
+ this._updateNavButtons();
666
+ this._emit('queuechange', {queue: this.queue, currentIndex: this.currentIndex});
667
+ if (this.config.onQueueChange) this.config.onQueueChange(this.queue, this.currentIndex);
668
+ return this;
669
+ }
670
+
671
+ getCurrentTrack() {
672
+ return (this.currentIndex >= 0 && this.currentIndex < this.queue.length) ? this.queue[this.currentIndex] : null;
673
+ }
674
+
675
+ getQueue() { return [...this.queue]; }
676
+ getCurrentIndex() { return this.currentIndex; }
677
+ isCurrentlyPlaying(url) { const c = this.getCurrentTrack(); return this.isPlaying && c && c.url === url; }
678
+ isCurrentTrack(url) { const c = this.getCurrentTrack(); return c && c.url === url; }
679
+ getPlayer() { return this.player; }
680
+
681
+ // =====================================================================
682
+ // Events
683
+ // =====================================================================
684
+
685
+ /**
686
+ * Dispatch a custom DOM event on the bar element.
687
+ * All events bubble and are prefixed with 'waveformbar:'.
688
+ *
689
+ * Events dispatched:
690
+ * - waveformbar:play { track }
691
+ * - waveformbar:pause { track }
692
+ * - waveformbar:trackchange { track, index }
693
+ * - waveformbar:markerchange { marker, index, track }
694
+ * - waveformbar:favorite { track, favorited }
695
+ * - waveformbar:cart { track }
696
+ * - waveformbar:queuechange { queue, currentIndex }
697
+ * - waveformbar:volumechange { volume }
698
+ *
699
+ * @private
700
+ * @param {string} name - Event name (without prefix)
701
+ * @param {Object} detail - Event detail data
702
+ */
703
+ _emit(name, detail = {}) {
704
+ if (!this.barEl) return;
705
+ this.barEl.dispatchEvent(new CustomEvent('waveformbar:' + name, {
706
+ bubbles: true,
707
+ detail
708
+ }));
709
+ }
710
+
711
+ // =====================================================================
712
+ // UI: Bar visibility & Queue panel
713
+ // =====================================================================
714
+
715
+ show() { if (this.barEl) this.barEl.classList.add('wb-active'); return this; }
716
+ hide() { if (this.barEl) this.barEl.classList.remove('wb-active'); this.closeQueuePanel(); this.closeVolumePopup(); return this; }
717
+
718
+ toggleQueuePanel() { return this.queueOpen ? this.closeQueuePanel() : this.openQueuePanel(); }
719
+
720
+ openQueuePanel() {
721
+ if (!this.queueEl) return this;
722
+ this.queueOpen = true;
723
+ this.closeVolumePopup();
724
+
725
+ // Position above the queue button
726
+ if (this.queueBtnEl) {
727
+ const rect = this.queueBtnEl.getBoundingClientRect();
728
+ this.queueEl.style.right = (window.innerWidth - rect.right) + 'px';
729
+ }
730
+
731
+ this.queueEl.classList.add('wb-queue-open');
732
+ if (this.queueBtnEl) this.queueBtnEl.classList.add('wb-active');
733
+ this._renderQueue();
734
+ return this;
735
+ }
736
+
737
+ closeQueuePanel() {
738
+ if (!this.queueEl) return this;
739
+ this.queueOpen = false;
740
+ this.queueEl.classList.remove('wb-queue-open');
741
+ if (this.queueBtnEl) this.queueBtnEl.classList.remove('wb-active');
742
+ return this;
743
+ }
744
+
745
+ toggleVolumePopup() {
746
+ if (this.volumePopupEl?.classList.contains('wb-volume-open')) {
747
+ this.closeVolumePopup();
748
+ } else {
749
+ this.openVolumePopup();
750
+ }
751
+ return this;
752
+ }
753
+
754
+ openVolumePopup() {
755
+ if (!this.volumePopupEl) return this;
756
+ this.closeQueuePanel();
757
+ this.volumePopupEl.classList.add('wb-volume-open');
758
+ if (this.muteBtnEl) this.muteBtnEl.classList.add('wb-active');
759
+ return this;
760
+ }
761
+
762
+ closeVolumePopup() {
763
+ if (!this.volumePopupEl) return this;
764
+ this.volumePopupEl.classList.remove('wb-volume-open');
765
+ if (this.muteBtnEl) this.muteBtnEl.classList.remove('wb-active');
766
+ return this;
767
+ }
768
+
769
+ // =====================================================================
770
+ // Internal: Loading & Display
771
+ // =====================================================================
772
+
773
+ _loadCurrentTrack() {
774
+ const track = this.getCurrentTrack();
775
+ if (!track || !this.player) return;
776
+
777
+ this.show();
778
+ this._updateTrackDisplay(track);
779
+ this._updateFavoriteUI();
780
+
781
+ const loadOpts = {artwork: track.artwork, album: track.album};
782
+ if (track.waveform) loadOpts.waveform = track.waveform;
783
+
784
+ // Always pass markers — empty array clears previous track's markers
785
+ if (track.markers && track.markers.length) {
786
+ const defaultColor = this.config.markerColor;
787
+ loadOpts.markers = track.markers.map(m => ({
788
+ ...m,
789
+ color: m.color || defaultColor
790
+ }));
791
+ } else {
792
+ loadOpts.markers = [];
793
+ }
794
+ this.player.loadTrack(track.url, track.title, track.artist, loadOpts);
795
+
796
+ // Store markers for DJ mode (dynamic title/artist updates)
797
+ this._activeMarkers = track.markers && track.markers.length ? track.markers : null;
798
+ this._currentMarkerIndex = -1;
799
+
800
+ if (this.player) this.player.setVolume(this.isMuted ? 0 : this.volume);
801
+
802
+ this._renderQueue();
803
+ this._syncPageState();
804
+ this._saveState();
805
+ this._updateNavButtons();
806
+
807
+ this._emit('trackchange', {track, index: this.currentIndex});
808
+ if (this.config.onTrackChange) this.config.onTrackChange(track, this.currentIndex);
809
+ }
810
+
811
+ _updateTrackDisplay(track) {
812
+ if (this.titleEl) this._setScrollText(this.titleEl, track.title || 'Untitled');
813
+ if (this.artistEl) this._setScrollText(this.artistEl, track.artist || '');
814
+
815
+ const artworkEl = this.barEl.querySelector('.wb-artwork');
816
+ if (artworkEl) {
817
+ const artworkUrl = track.artwork || this.config.defaultArtwork;
818
+ artworkEl.innerHTML = artworkUrl
819
+ ? `<img src="${escapeHtml(artworkUrl)}" alt="${escapeHtml(track.title)}" />`
820
+ : ICONS.music;
821
+ }
822
+
823
+ if (this.metaEl && this.config.showMeta) this._renderMeta(track);
824
+
825
+ const trackEl = this.barEl.querySelector('.wb-track');
826
+ if (trackEl) trackEl.style.cursor = track.link ? 'pointer' : 'default';
827
+
828
+ // Reset time
829
+ if (this.timeCurrentEl) this.timeCurrentEl.textContent = '0:00';
830
+ if (this.timeTotalEl) this.timeTotalEl.textContent = '0:00';
831
+ }
832
+
833
+ /**
834
+ * Set text on an element with auto-scroll if it overflows.
835
+ * @private
836
+ */
837
+ _setScrollText(el, text) {
838
+ el.classList.remove('wb-scrolling');
839
+ el.textContent = text;
840
+
841
+ // Check overflow after text is set
842
+ requestAnimationFrame(() => {
843
+ if (el.scrollWidth > el.clientWidth) {
844
+ const overflow = el.scrollWidth - el.clientWidth;
845
+ const duration = Math.max(4, overflow / 20); // ~20px/sec
846
+ el.innerHTML = `<span class="wb-scroll-inner">${escapeHtml(text)}</span>`;
847
+ el.style.setProperty('--wb-scroll-distance', `-${overflow + 48}px`);
848
+ el.style.setProperty('--wb-scroll-duration', `${duration}s`);
849
+ el.classList.add('wb-scrolling');
850
+ }
851
+ });
852
+ }
853
+
854
+ _renderMeta(track) {
855
+ if (!this.metaEl) return;
856
+ const tags = [];
857
+ if (track.bpm) tags.push({label: track.bpm + ' BPM', type: 'bpm'});
858
+ if (track.key) tags.push({label: track.key, type: 'key'});
859
+ if (track.duration) tags.push({label: track.duration, type: 'duration'});
860
+ if (track.meta) {
861
+ for (const [k, v] of Object.entries(track.meta)) {
862
+ if (v && tags.length < this.config.maxMeta) tags.push({label: String(v), type: k});
863
+ }
864
+ }
865
+ const limited = tags.slice(0, this.config.maxMeta);
866
+ this.metaEl.style.display = limited.length ? 'flex' : 'none';
867
+ this.metaEl.innerHTML = limited.map(t =>
868
+ `<span class="wb-tag wb-tag-${escapeHtml(t.type)}">${escapeHtml(t.label)}</span>`
869
+ ).join('');
870
+ }
871
+
872
+ _updatePlayButton() {
873
+ if (!this.playBtnEl) return;
874
+ const play = this.playBtnEl.querySelector('.wb-icon-play');
875
+ const pause = this.playBtnEl.querySelector('.wb-icon-pause');
876
+ if (play) play.style.display = this.isPlaying ? 'none' : 'block';
877
+ if (pause) pause.style.display = this.isPlaying ? 'block' : 'none';
878
+ this.playBtnEl.title = this.isPlaying ? 'Pause' : 'Play';
879
+ }
880
+
881
+ _updateNavButtons() {
882
+ const prevBtn = this.barEl?.querySelector('.wb-prev');
883
+ const nextBtn = this.barEl?.querySelector('.wb-next');
884
+ if (this.repeat === 'all') {
885
+ // When repeat-all, nav is always available (wraps around)
886
+ if (prevBtn) prevBtn.classList.remove('wb-disabled');
887
+ if (nextBtn) nextBtn.classList.remove('wb-disabled');
888
+ } else {
889
+ if (prevBtn) prevBtn.classList.toggle('wb-disabled', this.currentIndex <= 0);
890
+ if (nextBtn) nextBtn.classList.toggle('wb-disabled', this.currentIndex >= this.queue.length - 1);
891
+ }
892
+ }
893
+
894
+ // =====================================================================
895
+ // Repeat
896
+ // =====================================================================
897
+
898
+ /**
899
+ * Cycle through repeat modes: off → all → one → off
900
+ * @returns {WaveformBar}
901
+ */
902
+ cycleRepeat() {
903
+ const modes = ['off', 'all', 'one'];
904
+ const current = modes.indexOf(this.repeat);
905
+ this.repeat = modes[(current + 1) % modes.length];
906
+ this._updateRepeatButton();
907
+ this._updateNavButtons();
908
+ this._emit('repeatchange', {mode: this.repeat});
909
+ return this;
910
+ }
911
+
912
+ /**
913
+ * Set repeat mode directly
914
+ * @param {'off'|'all'|'one'} mode
915
+ * @returns {WaveformBar}
916
+ */
917
+ setRepeat(mode) {
918
+ if (['off', 'all', 'one'].includes(mode)) {
919
+ this.repeat = mode;
920
+ this._updateRepeatButton();
921
+ this._updateNavButtons();
922
+ this._emit('repeatchange', {mode: this.repeat});
923
+ }
924
+ return this;
925
+ }
926
+
927
+ /** @private */
928
+ _updateRepeatButton() {
929
+ if (!this.repeatBtnEl) return;
930
+ const icons = {off: ICONS.repeatOff, all: ICONS.repeatAll, one: ICONS.repeatOne};
931
+ const labels = {off: 'Repeat: Off', all: 'Repeat: All', one: 'Repeat: One'};
932
+ this.repeatBtnEl.innerHTML = icons[this.repeat];
933
+ this.repeatBtnEl.title = labels[this.repeat];
934
+ this.repeatBtnEl.classList.toggle('wb-repeat-active', this.repeat !== 'off');
935
+ }
936
+
937
+ /**
938
+ * DJ mode: check if playback has crossed a marker boundary
939
+ * and update the bar's title/artist/artwork/meta display.
940
+ * Markers should be sorted by time and can include:
941
+ * { time, label, title, artist, artwork, bpm, key }
942
+ * @private
943
+ */
944
+ _checkMarkerBoundary(currentTime) {
945
+ if (!this._activeMarkers) return;
946
+
947
+ // Find the active marker (last marker whose time <= currentTime)
948
+ let markerIndex = -1;
949
+ for (let i = this._activeMarkers.length - 1; i >= 0; i--) {
950
+ if (currentTime >= this._activeMarkers[i].time) {
951
+ markerIndex = i;
952
+ break;
953
+ }
954
+ }
955
+
956
+ // Only update if we've moved to a different marker
957
+ if (markerIndex === this._currentMarkerIndex) return;
958
+ this._currentMarkerIndex = markerIndex;
959
+
960
+ if (markerIndex < 0) return;
961
+
962
+ const marker = this._activeMarkers[markerIndex];
963
+ const track = this.getCurrentTrack();
964
+
965
+ // Update title/artist if the marker provides them
966
+ if (marker.title && this.titleEl) this._setScrollText(this.titleEl, marker.title);
967
+ if (marker.artist && this.artistEl) this._setScrollText(this.artistEl, marker.artist);
968
+
969
+ // Highlight the active marker on the waveform
970
+ const markerEls = this.waveformContainer?.querySelectorAll('.waveform-marker');
971
+ if (markerEls) {
972
+ markerEls.forEach((el, i) => el.classList.toggle('wb-marker-active', i === markerIndex));
973
+ }
974
+
975
+ // Update artwork if provided
976
+ if (marker.artwork) {
977
+ const artworkEl = this.barEl.querySelector('.wb-artwork');
978
+ if (artworkEl) artworkEl.innerHTML = `<img src="${marker.artwork}" alt="${marker.title || ''}" />`;
979
+ }
980
+
981
+ // Update meta tags if bpm/key provided
982
+ if (this.metaEl && (marker.bpm || marker.key)) {
983
+ const metaTrack = {
984
+ ...(track || {}),
985
+ bpm: marker.bpm || '',
986
+ key: marker.key || ''
987
+ };
988
+ this._renderMeta(metaTrack);
989
+ }
990
+
991
+ this._emit('markerchange', {marker, index: markerIndex, track});
992
+ }
993
+
994
+ _updateVolumeUI() {
995
+ if (this.volumeSliderEl) {
996
+ this.volumeSliderEl.value = this.isMuted ? 0 : Math.round(this.volume * 100);
997
+ }
998
+ if (this.muteBtnEl) {
999
+ if (this.isMuted || this.volume === 0) {
1000
+ this.muteBtnEl.innerHTML = ICONS.volMute;
1001
+ this.muteBtnEl.classList.add('wb-muted');
1002
+ this.muteBtnEl.title = 'Unmute';
1003
+ } else if (this.volume < 0.5) {
1004
+ this.muteBtnEl.innerHTML = ICONS.volLow;
1005
+ this.muteBtnEl.classList.remove('wb-muted');
1006
+ this.muteBtnEl.title = 'Mute';
1007
+ } else {
1008
+ this.muteBtnEl.innerHTML = ICONS.volHigh;
1009
+ this.muteBtnEl.classList.remove('wb-muted');
1010
+ this.muteBtnEl.title = 'Mute';
1011
+ }
1012
+ }
1013
+ }
1014
+
1015
+ /**
1016
+ * Auto-detect light/dark theme from the page.
1017
+ * Checks: 1) HTML/body classes, 2) background brightness, 3) system preference
1018
+ * @private
1019
+ * @returns {'dark'|'light'}
1020
+ */
1021
+ _detectTheme() {
1022
+ const root = document.documentElement;
1023
+ const body = document.body;
1024
+
1025
+ // 1. Explicit theme classes/attributes
1026
+ const darkIndicators = ['dark', 'dark-mode', 'theme-dark'];
1027
+ const lightIndicators = ['light', 'light-mode', 'theme-light'];
1028
+
1029
+ for (const cls of darkIndicators) {
1030
+ if (root.classList.contains(cls) || body.classList.contains(cls)) return 'dark';
1031
+ }
1032
+ if (root.getAttribute('data-theme') === 'dark' || body.getAttribute('data-theme') === 'dark') return 'dark';
1033
+
1034
+ for (const cls of lightIndicators) {
1035
+ if (root.classList.contains(cls) || body.classList.contains(cls)) return 'light';
1036
+ }
1037
+ if (root.getAttribute('data-theme') === 'light' || body.getAttribute('data-theme') === 'light') return 'light';
1038
+
1039
+ // 2. Background brightness
1040
+ try {
1041
+ const bg = getComputedStyle(body).backgroundColor;
1042
+ const rgb = bg.match(/\d+/g);
1043
+ if (rgb && rgb.length >= 3) {
1044
+ const brightness = (rgb[0] * 299 + rgb[1] * 587 + rgb[2] * 114) / 1000;
1045
+ if (brightness > 128) return 'light';
1046
+ if (brightness < 128) return 'dark';
1047
+ }
1048
+ } catch (e) {}
1049
+
1050
+ // 3. System preference
1051
+ if (window.matchMedia?.('(prefers-color-scheme: light)').matches) return 'light';
1052
+
1053
+ return 'dark';
1054
+ }
1055
+
1056
+ _updateFavoriteUI() {
1057
+ if (!this.favBtnEl) return;
1058
+ const fav = this.isFavorited();
1059
+ this.favBtnEl.innerHTML = fav ? ICONS.heartFilled : ICONS.heart;
1060
+ this.favBtnEl.classList.toggle('wb-fav-active', fav);
1061
+ }
1062
+
1063
+ _renderQueue() {
1064
+ renderQueue(this.queueBodyEl, this.queueCountEl, this.queue, this.currentIndex, {
1065
+ onSkipTo: (i) => this.skipTo(i),
1066
+ onRemove: (i) => this.removeFromQueue(i)
1067
+ });
1068
+ }
1069
+
1070
+ // =====================================================================
1071
+ // Page State Sync
1072
+ // =====================================================================
1073
+
1074
+ /**
1075
+ * Sync all state classes and attributes back to page trigger elements.
1076
+ *
1077
+ * Classes applied:
1078
+ * - .wb-current — track is current (playing or paused)
1079
+ * - .wb-playing — track is actively playing
1080
+ * - .wb-favorited — track is in favorites
1081
+ * - .wb-in-cart — track has been added to cart
1082
+ * @private
1083
+ */
1084
+ _syncPageState() {
1085
+ const current = this.getCurrentTrack();
1086
+ const currentUrl = current ? current.url : null;
1087
+
1088
+ document.querySelectorAll('[data-wb-play]').forEach(el => {
1089
+ const url = el.dataset.wbUrl || el.dataset.url;
1090
+ const id = el.dataset.wbId || el.dataset.id || url;
1091
+ const isCurrent = url && url === currentUrl;
1092
+
1093
+ // Play state
1094
+ el.classList.toggle('wb-current', isCurrent);
1095
+ el.classList.toggle('wb-playing', isCurrent && this.isPlaying);
1096
+
1097
+ // Favorite state
1098
+ el.classList.toggle('wb-favorited', this._favorites.has(id));
1099
+
1100
+ // Cart state
1101
+ el.classList.toggle('wb-in-cart', this._cartItems.has(id));
1102
+ });
1103
+ }
1104
+
1105
+ /**
1106
+ * Seed favorites and cart state from data attributes on page elements.
1107
+ * This is the authoritative source — server renders the initial state,
1108
+ * and we read it on init. Overrides localStorage.
1109
+ * @private
1110
+ */
1111
+ _seedFromAttributes() {
1112
+ let seededFav = false;
1113
+ let seededCart = false;
1114
+
1115
+ document.querySelectorAll('[data-wb-play]').forEach(el => {
1116
+ const id = el.dataset.wbId || el.dataset.id || el.dataset.wbUrl || el.dataset.url;
1117
+ if (!id) return;
1118
+
1119
+ // Seed favorites from data-wb-favorited="true"
1120
+ if (el.dataset.wbFavorited === 'true') {
1121
+ this._favorites.add(id);
1122
+ seededFav = true;
1123
+ }
1124
+
1125
+ // Seed cart from data-wb-in-cart="true"
1126
+ if (el.dataset.wbInCart === 'true') {
1127
+ this._cartItems.add(id);
1128
+ seededCart = true;
1129
+ }
1130
+ });
1131
+
1132
+ // If we seeded from attributes, save to storage so it persists
1133
+ if (seededFav) {
1134
+ saveFavorites(this.config.storageKey, this._favorites);
1135
+ }
1136
+ }
1137
+
1138
+ /**
1139
+ * Sync favorite state back to trigger element data attributes
1140
+ * @private
1141
+ * @param {string} url - Track URL to match
1142
+ * @param {boolean} favorited - New state
1143
+ */
1144
+ _syncFavoriteAttributes(url, favorited) {
1145
+ document.querySelectorAll('[data-wb-play]').forEach(el => {
1146
+ const elUrl = el.dataset.wbUrl || el.dataset.url;
1147
+ if (elUrl === url) {
1148
+ el.dataset.wbFavorited = favorited ? 'true' : 'false';
1149
+ el.classList.toggle('wb-favorited', favorited);
1150
+ }
1151
+ });
1152
+ }
1153
+
1154
+ /**
1155
+ * Sync cart state back to trigger element data attributes
1156
+ * @private
1157
+ * @param {string} url - Track URL to match
1158
+ * @param {boolean} inCart - New state
1159
+ */
1160
+ _syncCartAttributes(url, inCart) {
1161
+ document.querySelectorAll('[data-wb-play]').forEach(el => {
1162
+ const elUrl = el.dataset.wbUrl || el.dataset.url;
1163
+ if (elUrl === url) {
1164
+ el.dataset.wbInCart = inCart ? 'true' : 'false';
1165
+ el.classList.toggle('wb-in-cart', inCart);
1166
+ }
1167
+ });
1168
+ }
1169
+
1170
+ // =====================================================================
1171
+ // Persistence
1172
+ // =====================================================================
1173
+
1174
+ _saveState() {
1175
+ if (!this.config.persist) return;
1176
+ saveQueueState(this.config.storageKey, {
1177
+ queue: this.queue,
1178
+ currentIndex: this.currentIndex,
1179
+ position: this._lastPosition || 0,
1180
+ isPlaying: this.isPlaying
1181
+ });
1182
+ }
1183
+
1184
+ _restoreState() {
1185
+ if (!this.config.persist) return;
1186
+ const state = restoreQueueState(this.config.storageKey);
1187
+ if (!state) return;
1188
+
1189
+ this.queue = state.queue;
1190
+ this.currentIndex = state.currentIndex;
1191
+
1192
+ const track = this.getCurrentTrack();
1193
+ if (!track) return;
1194
+
1195
+ this.show();
1196
+ this._updateTrackDisplay(track);
1197
+ this._updateFavoriteUI();
1198
+ this._updateNavButtons();
1199
+
1200
+ // Use load() instead of loadTrack() to avoid auto-play.
1201
+ // We handle seek and play manually after the audio is ready.
1202
+ if (track.waveform) this.player.options.waveform = track.waveform;
1203
+ this.player.options.title = track.title || '';
1204
+ this.player.options.subtitle = track.artist || '';
1205
+
1206
+ // Pass markers to the player and set up DJ mode
1207
+ if (track.markers && track.markers.length) {
1208
+ const defaultColor = this.config.markerColor;
1209
+ this.player.options.markers = track.markers.map(m => ({
1210
+ ...m,
1211
+ color: m.color || defaultColor
1212
+ }));
1213
+ this._activeMarkers = track.markers;
1214
+ } else {
1215
+ this.player.options.markers = [];
1216
+ this._activeMarkers = null;
1217
+ }
1218
+ this._currentMarkerIndex = -1;
1219
+
1220
+ this.player.load(track.url).then(() => {
1221
+ if (this.player) this.player.setVolume(this.isMuted ? 0 : this.volume);
1222
+
1223
+ console.log('RESTORE: position =', state.position, 'duration =', this.player?.audio?.duration);
1224
+
1225
+ if (state.isPlaying && this.config.autoResume) {
1226
+ try {
1227
+ const p = this.player.play();
1228
+ if (p && typeof p.catch === 'function') {
1229
+ p.catch(() => {
1230
+ this.isPlaying = false;
1231
+ this._updatePlayButton();
1232
+ this._syncPageState();
1233
+ });
1234
+ }
1235
+ } catch (e) {
1236
+ this.isPlaying = false;
1237
+ this._updatePlayButton();
1238
+ this._syncPageState();
1239
+ }
1240
+ }
1241
+
1242
+ // Seek after play — the audio element needs to be in a playing
1243
+ // or ready state for seek to stick reliably
1244
+ if (state.position > 0) {
1245
+ setTimeout(() => {
1246
+ if (this.player) {
1247
+ this.player.seekTo(state.position);
1248
+ this._lastPosition = state.position;
1249
+ }
1250
+ }, 100);
1251
+ }
1252
+ }).catch(() => {});
1253
+
1254
+ this._renderQueue();
1255
+ this._syncPageState();
1256
+ }
1257
+
1258
+ _restoreVolume() {
1259
+ const data = restoreVolume(this.config.storageKey);
1260
+ if (!data) return;
1261
+ this.volume = data.volume;
1262
+ this.isMuted = data.muted;
1263
+ this._volumeBeforeMute = data.volumeBeforeMute;
1264
+ if (this.player) this.player.setVolume(this.isMuted ? 0 : this.volume);
1265
+ this._updateVolumeUI();
1266
+ }
1267
+
1268
+ _restoreFavorites() {
1269
+ this._favorites = restoreFavorites(this.config.storageKey);
1270
+ }
1271
+ }