@arraypress/waveform-bar 1.3.1 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/js/core.js CHANGED
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import {ICONS} from './icons.js';
7
- import {extractTitle, escapeHtml, formatTime, parseTrackFromElement} from './utils.js';
7
+ import {extractTitle, escapeHtml, formatTime, parseTrackFromElement, isSafeHref} from './utils.js';
8
8
  import {
9
9
  saveQueueState,
10
10
  restoreQueueState,
@@ -36,10 +36,18 @@ const DEFAULTS = {
36
36
  maxMeta: 3,
37
37
  defaultArtwork: null, // URL to fallback artwork image
38
38
  theme: null, // 'dark', 'light', or null (dark by default)
39
+ wide: false, // true = content spans full width (lifts the 1400px cap)
40
+ maxWidth: null, // custom content max-width (CSS value), e.g. '1200px'; overrides `wide`
41
+ position: 'bottom', // 'bottom' (default) or 'top' — which edge the bar docks to
42
+ collapsible: false, // show a collapse button that shrinks the bar to a floating transport pill
43
+ waveform: true, // false = classic Spotify-style seek bar instead of the waveform
44
+ errorText: null, // custom "audio failed to load" message (null = player default)
45
+ share: false, // show a "copy share link" button (emits ?<shareParam>=<seconds>)
46
+ shareParam: 'wt', // URL query param for the shared timestamp (seconds)
39
47
  waveformStyle: 'mirror',
40
48
  waveformHeight: 32,
41
49
  barWidth: 2,
42
- barSpacing: 0,
50
+ barSpacing: 2, // 2px gap between 2px bars — crisp, separated bars (0 = solid "blob")
43
51
  waveformColor: null,
44
52
  progressColor: null,
45
53
  markerColor: 'rgba(255, 255, 255, 0.25)',
@@ -75,6 +83,15 @@ export class WaveformBar {
75
83
  this._currentMarkerIndex = -1;
76
84
  this.repeat = 'off'; // 'off', 'all', 'one'
77
85
 
86
+ // Load-race guard: bumped before every track load so async
87
+ // continuations (loadTrack().then / restore-seek timeout) can detect
88
+ // that a newer load superseded them and bail out.
89
+ this._loadSeq = 0;
90
+ this._restoreSeekTimeout = null;
91
+
92
+ // Map<url, Set<WaveformPlayer>> of external-mode visual surfaces.
93
+ this._externalPlayers = new Map();
94
+
78
95
  // DOM refs
79
96
  this.barEl = null;
80
97
  this.queueEl = null;
@@ -109,10 +126,24 @@ export class WaveformBar {
109
126
  if (this.isInitialized) this.destroy();
110
127
 
111
128
  this.config = {...DEFAULTS, ...config};
112
- this.volume = this.config.volume;
129
+
130
+ // Normalize volume from config — guard NaN / out-of-range so a bad
131
+ // value can't mute the player or throw downstream.
132
+ const v = Number(this.config.volume);
133
+ this.volume = Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 1;
134
+
135
+ // Shareable timestamp: parse the share link's `?<shareParam>=<seconds>`
136
+ // (time) plus the track identity (`?wid` = id, `?wu` = url fallback,
137
+ // `?wtitle`/`?wartist` for display). When the link names a track we
138
+ // cold-load it below; a bare `?<shareParam>=` with no track just seeks
139
+ // whatever loads first (applied once in the player onLoad hook).
140
+ this._shareTarget = this._readShareTarget();
141
+ this._shareSeek = (this._shareTarget && !this._shareTarget.id && !this._shareTarget.url)
142
+ ? this._shareTarget.time
143
+ : null;
113
144
 
114
145
  if (typeof window.WaveformPlayer === 'undefined') {
115
- console.error('WaveformBar: WaveformPlayer is required.');
146
+ console.error('[WaveformBar] WaveformPlayer is required.');
116
147
  return this;
117
148
  }
118
149
 
@@ -135,6 +166,15 @@ export class WaveformBar {
135
166
  this._restoreState();
136
167
  }
137
168
 
169
+ // A share link that names a track (`?wid`/`?wu`) cold-loads it here —
170
+ // AFTER restore, so the shared track wins over the visitor's own last
171
+ // session. It loads paused at the shared timestamp (autoplay is blocked
172
+ // on a cold page open anyway, and landing cued is the right UX).
173
+ if (this._shareTarget && (this._shareTarget.id || this._shareTarget.url)) {
174
+ const shared = this._resolveSharedTrack(this._shareTarget);
175
+ if (shared) this._loadSharedTrack(shared, this._shareTarget.time);
176
+ }
177
+
138
178
  this.isInitialized = true;
139
179
 
140
180
  // Save exact position when navigating away
@@ -153,6 +193,45 @@ export class WaveformBar {
153
193
  this.player.destroy();
154
194
  this.player = null;
155
195
  }
196
+ if (this._docClickVolume) {
197
+ document.removeEventListener('click', this._docClickVolume);
198
+ this._docClickVolume = null;
199
+ }
200
+ if (this._docClickQueue) {
201
+ document.removeEventListener('click', this._docClickQueue);
202
+ this._docClickQueue = null;
203
+ }
204
+ // Single delegated trigger listener (replaces per-element binding).
205
+ if (this._docClickTriggers) {
206
+ document.removeEventListener('click', this._docClickTriggers);
207
+ this._docClickTriggers = null;
208
+ }
209
+ // External-mode WaveformPlayer document listeners — previously
210
+ // anonymous and never removed. Stored on `this` so they can be torn
211
+ // down and the bind flag reset for a clean re-init.
212
+ if (this._externalListenersBound) {
213
+ document.removeEventListener('waveformplayer:request-play', this._onExtRequestPlay);
214
+ document.removeEventListener('waveformplayer:request-pause', this._onExtRequestPause);
215
+ document.removeEventListener('waveformplayer:request-seek', this._onExtRequestSeek);
216
+ document.removeEventListener('waveformplayer:destroy', this._onExtDestroy);
217
+ this._onExtRequestPlay = null;
218
+ this._onExtRequestPause = null;
219
+ this._onExtRequestSeek = null;
220
+ this._onExtDestroy = null;
221
+ this._externalListenersBound = false;
222
+ }
223
+ this._externalPlayers = new Map();
224
+
225
+ // Cancel pending async work that could touch torn-down refs.
226
+ if (this._restoreSeekTimeout) {
227
+ clearTimeout(this._restoreSeekTimeout);
228
+ this._restoreSeekTimeout = null;
229
+ }
230
+ clearTimeout(this._shareFlashTimeout);
231
+ this._shareFlashTimeout = null;
232
+ // Invalidate any in-flight load continuation.
233
+ this._loadSeq++;
234
+
156
235
  if (this.barEl) {
157
236
  this.barEl.remove();
158
237
  this.barEl = null;
@@ -170,7 +249,24 @@ export class WaveformBar {
170
249
  this._beforeUnloadHandler = null;
171
250
  }
172
251
 
173
- document.querySelectorAll('[data-wb-play],[data-wb-queue]').forEach(el => delete el._wbBound);
252
+ // Null cached DOM refs so a stale ref can't be poked after teardown.
253
+ this.volumePopupEl = null;
254
+ this.queueBtnEl = null;
255
+ this.titleEl = null;
256
+ this.artistEl = null;
257
+ this.metaEl = null;
258
+ this.playBtnEl = null;
259
+ this.repeatBtnEl = null;
260
+ this.waveformContainer = null;
261
+ this.queueBodyEl = null;
262
+ this.queueCountEl = null;
263
+ this.muteBtnEl = null;
264
+ this.volumeSliderEl = null;
265
+ this.favBtnEl = null;
266
+ this.cartBtnEl = null;
267
+ this.timeCurrentEl = null;
268
+ this.timeTotalEl = null;
269
+
174
270
  document.querySelectorAll('.wb-current,.wb-playing').forEach(el => el.classList.remove('wb-current', 'wb-playing'));
175
271
 
176
272
  this.queue = [];
@@ -195,6 +291,14 @@ export class WaveformBar {
195
291
  if (theme === 'light') this.barEl.classList.add('wb-light');
196
292
  this._resolvedTheme = theme;
197
293
 
294
+ // Layout width: `maxWidth` wins; else `wide` lifts the cap to full width.
295
+ // Default (neither set) leaves the stylesheet's 1400px cap in place.
296
+ const maxWidth = this.config.maxWidth || (this.config.wide ? '100%' : null);
297
+ if (maxWidth) this.barEl.style.setProperty('--wb-max-width', maxWidth);
298
+
299
+ // Dock to the top edge instead of the bottom (flips slide direction).
300
+ if (this.config.position === 'top') this.barEl.classList.add('wb-top');
301
+
198
302
  this.barEl.id = 'waveform-bar';
199
303
  this.barEl.innerHTML = buildBarHTML(this.config);
200
304
  document.body.appendChild(this.barEl);
@@ -206,20 +310,29 @@ export class WaveformBar {
206
310
  this.playBtnEl = this.barEl.querySelector('.wb-play');
207
311
  this.waveformContainer = this.barEl.querySelector('.wb-waveform-container');
208
312
  this.queueBtnEl = this.barEl.querySelector('.wb-queue-btn');
313
+ this.shareBtnEl = this.barEl.querySelector('.wb-share');
209
314
  this.muteBtnEl = this.barEl.querySelector('.wb-mute');
210
315
  this.volumeSliderEl = this.barEl.querySelector('.wb-volume-slider');
211
316
  this.favBtnEl = this.barEl.querySelector('.wb-fav');
212
317
  this.cartBtnEl = this.barEl.querySelector('.wb-cart');
213
318
  this.timeCurrentEl = this.barEl.querySelector('.wb-time-current');
214
319
  this.timeTotalEl = this.barEl.querySelector('.wb-time-total');
320
+ this.collapseBtnEl = this.barEl.querySelector('.wb-collapse');
215
321
 
216
322
  // Bind controls
217
323
  this.playBtnEl.addEventListener('click', () => this.togglePlay());
218
324
 
325
+ // Collapse-to-pill toggle.
326
+ if (this.collapseBtnEl) {
327
+ this.collapseBtnEl.addEventListener('click', () => this.toggleCollapse());
328
+ if (this._readCollapsed()) this.collapse();
329
+ }
330
+
219
331
  const prevBtn = this.barEl.querySelector('.wb-prev');
220
332
  const nextBtn = this.barEl.querySelector('.wb-next');
221
333
  if (prevBtn) prevBtn.addEventListener('click', () => this.previous());
222
334
  if (nextBtn) nextBtn.addEventListener('click', () => this.next());
335
+ if (this.shareBtnEl) this.shareBtnEl.addEventListener('click', () => this._share());
223
336
 
224
337
  this.repeatBtnEl = this.barEl.querySelector('.wb-repeat');
225
338
  if (this.repeatBtnEl) {
@@ -259,13 +372,16 @@ export class WaveformBar {
259
372
  });
260
373
  }
261
374
 
262
- // Close volume popup on outside click
263
- document.addEventListener('click', (e) => {
375
+ // Close volume popup on outside click.
376
+ // Stored on `this` so destroy() can remove it — otherwise every
377
+ // re-init (init() calls destroy() first) leaks another listener.
378
+ this._docClickVolume = (e) => {
264
379
  if (this.volumePopupEl?.classList.contains('wb-volume-open') &&
265
- !this.barEl.querySelector('.wb-volume')?.contains(e.target)) {
380
+ !this.barEl?.querySelector('.wb-volume')?.contains(e.target)) {
266
381
  this.closeVolumePopup();
267
382
  }
268
- });
383
+ };
384
+ document.addEventListener('click', this._docClickVolume);
269
385
 
270
386
  if (this.favBtnEl) this.favBtnEl.addEventListener('click', () => this.toggleFavorite());
271
387
  if (this.cartBtnEl) this.cartBtnEl.addEventListener('click', () => this.addToCart());
@@ -274,7 +390,9 @@ export class WaveformBar {
274
390
  if (this.config.showTrackLink) {
275
391
  this.barEl.querySelector('.wb-track').addEventListener('click', () => {
276
392
  const t = this.getCurrentTrack();
277
- if (t && t.link) window.location.href = t.link;
393
+ // Guard the scheme a `data-wb-link="javascript:…"` value
394
+ // would otherwise execute on navigation (open-redirect / XSS).
395
+ if (t && t.link && isSafeHref(t.link)) window.location.href = t.link;
278
396
  });
279
397
  }
280
398
  }
@@ -291,21 +409,28 @@ export class WaveformBar {
291
409
 
292
410
  this.queueEl.querySelector('.wb-queue-clear').addEventListener('click', () => this.clearQueue());
293
411
 
294
- document.addEventListener('click', (e) => {
295
- if (this.queueOpen && !this.queueEl.contains(e.target) && !this.queueBtnEl.contains(e.target)) {
412
+ // Stored on `this` so destroy() can remove it — otherwise every
413
+ // re-init leaks another document listener.
414
+ this._docClickQueue = (e) => {
415
+ if (this.queueOpen && !this.queueEl?.contains(e.target) && !this.queueBtnEl?.contains(e.target)) {
296
416
  this.closeQueuePanel();
297
417
  }
298
- });
418
+ };
419
+ document.addEventListener('click', this._docClickQueue);
299
420
  }
300
421
 
301
422
  _initPlayer() {
302
423
  const opts = {
303
424
  showControls: false,
304
425
  showInfo: false,
305
- waveformStyle: this.config.waveformStyle,
426
+ // Classic mode reuses the player's own built-in 'seekbar' style —
427
+ // a simple rounded progress bar (no waveform), with the player's
428
+ // native click-to-seek. No custom seek-bar DOM needed.
429
+ waveformStyle: this.config.waveform === false ? 'seekbar' : this.config.waveformStyle,
306
430
  height: this.config.waveformHeight,
307
431
  barWidth: this.config.barWidth,
308
432
  barSpacing: this.config.barSpacing,
433
+ errorText: this.config.errorText, // null -> player uses its own default
309
434
  singlePlay: false,
310
435
  onPlay: () => {
311
436
  this.isPlaying = true;
@@ -356,6 +481,26 @@ export class WaveformBar {
356
481
  this._loadCurrentTrack();
357
482
  }
358
483
  },
484
+ onError: () => {
485
+ // A track failed to load/decode (bad URL, 404, codec). Without
486
+ // this the queue dead-stops on the broken entry. Reset play
487
+ // state, notify, and — when continuous — skip to the next
488
+ // entry so one dead track doesn't halt the whole queue.
489
+ this.isPlaying = false;
490
+ this._updatePlayButton();
491
+ this._syncPageState();
492
+ this._pumpExternalPlayState(false);
493
+
494
+ const track = this.getCurrentTrack();
495
+ this._emit('error', {track});
496
+
497
+ // Auto-advance like onEnd (but never wrap via repeat-all —
498
+ // that risks an infinite loop if the first track is also dead).
499
+ if (this.config.continuous && this.currentIndex < this.queue.length - 1) {
500
+ this.currentIndex++;
501
+ this._loadCurrentTrack();
502
+ }
503
+ },
359
504
  onTimeUpdate: (currentTime, duration) => {
360
505
  this._lastPosition = currentTime;
361
506
  if (this.timeCurrentEl) this.timeCurrentEl.textContent = formatTime(currentTime);
@@ -377,7 +522,14 @@ export class WaveformBar {
377
522
  this._checkMarkerBoundary(currentTime);
378
523
  }
379
524
  },
380
- onLoad: null
525
+ onLoad: () => {
526
+ // Apply a shareable-timestamp seek (?<shareParam>=) once, on the
527
+ // first track load after a fresh page open.
528
+ if (this._shareSeek != null && this.player) {
529
+ this.player.seekTo(this._shareSeek);
530
+ this._shareSeek = null;
531
+ }
532
+ }
381
533
  };
382
534
 
383
535
  if (this.config.waveformColor) opts.waveformColor = this.config.waveformColor;
@@ -392,26 +544,34 @@ export class WaveformBar {
392
544
  // =====================================================================
393
545
 
394
546
  _bindTriggers() {
395
- document.querySelectorAll('[data-wb-play]').forEach(el => {
396
- if (el._wbBound) return;
397
- el._wbBound = true;
398
- el.addEventListener('click', (e) => {
399
- e.preventDefault();
400
- const track = parseTrackFromElement(el);
401
- if (track) this.play(track);
402
- });
403
- });
547
+ // ONE delegated document listener instead of per-element binding.
548
+ // Per-element listeners leaked on every re-init (init() → destroy()
549
+ // only cleared a flag, never removed the handlers), leaving each row
550
+ // with stacked handlers that toggled each other so clicks did nothing.
551
+ // Delegation also covers late-mounted triggers for free, so the
552
+ // MutationObserver no longer needs to re-bind anything.
553
+ if (!this._docClickTriggers) {
554
+ this._docClickTriggers = (e) => {
555
+ // Queue first — a queue trigger nested in (or alongside) a
556
+ // play trigger must enqueue, not play. This mirrors the old
557
+ // per-element handler's stopPropagation() semantics.
558
+ const queueEl = e.target?.closest?.('[data-wb-queue]');
559
+ if (queueEl) {
560
+ e.preventDefault();
561
+ const track = parseTrackFromElement(queueEl);
562
+ if (track) this.addToQueue(track);
563
+ return;
564
+ }
404
565
 
405
- document.querySelectorAll('[data-wb-queue]').forEach(el => {
406
- if (el._wbBound) return;
407
- el._wbBound = true;
408
- el.addEventListener('click', (e) => {
409
- e.preventDefault();
410
- e.stopPropagation();
411
- const track = parseTrackFromElement(el);
412
- if (track) this.addToQueue(track);
413
- });
414
- });
566
+ const playEl = e.target?.closest?.('[data-wb-play]');
567
+ if (playEl) {
568
+ e.preventDefault();
569
+ const track = parseTrackFromElement(playEl);
570
+ if (track) this.play(track);
571
+ }
572
+ };
573
+ document.addEventListener('click', this._docClickTriggers);
574
+ }
415
575
 
416
576
  // External-mode WaveformPlayer instances on the page act as
417
577
  // visualization surfaces controlled by this bar — see the
@@ -435,18 +595,20 @@ export class WaveformBar {
435
595
  * @private
436
596
  */
437
597
  _attachExternalPlayers() {
438
- // Document-level listeners only bind once.
598
+ // Document-level listeners only bind once. Stored on `this` so
599
+ // destroy() can remove them (they were previously anonymous and
600
+ // leaked across re-inits).
439
601
  if (!this._externalListenersBound) {
440
602
  this._externalListenersBound = true;
441
603
 
442
- document.addEventListener('waveformplayer:request-play', (e) => {
604
+ this._onExtRequestPlay = (e) => {
443
605
  const t = e.detail;
444
606
  if (!t || !t.url) return;
445
607
  e.preventDefault();
446
608
  this.play(t);
447
- });
609
+ };
448
610
 
449
- document.addEventListener('waveformplayer:request-pause', (e) => {
611
+ this._onExtRequestPause = (e) => {
450
612
  const t = e.detail;
451
613
  if (!t || !t.url) return;
452
614
  // Only honour pause when this is the currently-playing
@@ -456,9 +618,9 @@ export class WaveformBar {
456
618
  e.preventDefault();
457
619
  if (this.isPlaying) this.togglePlay();
458
620
  }
459
- });
621
+ };
460
622
 
461
- document.addEventListener('waveformplayer:request-seek', (e) => {
623
+ this._onExtRequestSeek = (e) => {
462
624
  const t = e.detail;
463
625
  if (!t || !t.url || typeof t.percent !== 'number') return;
464
626
  const current = this.getCurrentTrack();
@@ -466,7 +628,20 @@ export class WaveformBar {
466
628
  e.preventDefault();
467
629
  this.player.seekToPercent(t.percent);
468
630
  }
469
- });
631
+ };
632
+
633
+ // Prune a destroyed external player from the URL → players map
634
+ // so we don't keep pumping state into a torn-down instance.
635
+ this._onExtDestroy = (e) => {
636
+ const inst = e.detail && e.detail.player;
637
+ if (!inst || !this._externalPlayers) return;
638
+ this._externalPlayers.forEach((set) => set.delete(inst));
639
+ };
640
+
641
+ document.addEventListener('waveformplayer:request-play', this._onExtRequestPlay);
642
+ document.addEventListener('waveformplayer:request-pause', this._onExtRequestPause);
643
+ document.addEventListener('waveformplayer:request-seek', this._onExtRequestSeek);
644
+ document.addEventListener('waveformplayer:destroy', this._onExtDestroy);
470
645
  }
471
646
 
472
647
  // Rebuild the URL → players map from scratch each pass — cheap
@@ -559,8 +734,12 @@ export class WaveformBar {
559
734
 
560
735
  _observeDOM() {
561
736
  if (typeof MutationObserver === 'undefined') return;
737
+ // Triggers are handled by the single delegated listener, so the
738
+ // observer only needs to (re)discover late-mounted external-mode
739
+ // players and re-sync page state.
740
+ // TODO(harden): debounce this callback.
562
741
  this._observer = new MutationObserver(() => {
563
- this._bindTriggers();
742
+ this._attachExternalPlayers();
564
743
  this._syncPageState();
565
744
  });
566
745
  this._observer.observe(document.body, {childList: true, subtree: true});
@@ -735,6 +914,9 @@ export class WaveformBar {
735
914
  this.isMuted = true;
736
915
  if (this.player) this.player.setVolume(0);
737
916
  this._updateVolumeUI();
917
+ // Persist the muted flag — the unmute branch routes through
918
+ // setVolume() (which saves), so mute must save too.
919
+ saveVolume(this.config.storageKey, this.volume, this.isMuted, this._volumeBeforeMute);
738
920
  }
739
921
  return this;
740
922
  }
@@ -782,7 +964,9 @@ export class WaveformBar {
782
964
  // Visual feedback on bar button
783
965
  if (this.cartBtnEl) {
784
966
  this.cartBtnEl.classList.add('wb-action-done');
785
- setTimeout(() => this.cartBtnEl.classList.remove('wb-action-done'), 1500);
967
+ setTimeout(() => {
968
+ if (this.cartBtnEl) this.cartBtnEl.classList.remove('wb-action-done');
969
+ }, 1500);
786
970
  }
787
971
 
788
972
  // Sync data attribute back to page triggers
@@ -973,10 +1157,285 @@ export class WaveformBar {
973
1157
  // Internal: Loading & Display
974
1158
  // =====================================================================
975
1159
 
1160
+ /**
1161
+ * Parse a share link from the URL: the timestamp (`?<shareParam>=`, seconds)
1162
+ * plus the track identity needed to load it cold — `?wid` (id, preferred),
1163
+ * `?wu` (url, the works-anywhere fallback), and `?wtitle`/`?wartist` for
1164
+ * display before metadata arrives. Returns null when no share params are
1165
+ * present. An unsafe `?wu` (javascript:/data: etc.) is dropped, not loaded.
1166
+ * @returns {{time:number, id:string|null, url:string|null, title:string|null, artist:string|null}|null}
1167
+ * @private
1168
+ */
1169
+ _readShareTarget() {
1170
+ let q;
1171
+ try {
1172
+ q = new URLSearchParams(window.location.search);
1173
+ } catch (e) {
1174
+ return null;
1175
+ }
1176
+ const rawTime = q.get(this.config.shareParam);
1177
+ const id = q.get('wid');
1178
+ const rawUrl = q.get('wu');
1179
+ if (rawTime == null && id == null && rawUrl == null) return null;
1180
+
1181
+ let time = 0;
1182
+ if (rawTime != null) {
1183
+ const t = Number(rawTime);
1184
+ if (Number.isFinite(t) && t >= 0) time = t;
1185
+ }
1186
+ // Only honour an embedded url that's a safe http(s)/relative target —
1187
+ // the value is attacker-controllable, so never let it be javascript:.
1188
+ const url = (rawUrl && isSafeHref(rawUrl)) ? rawUrl : null;
1189
+
1190
+ return {time, id: id || null, url, title: q.get('wtitle'), artist: q.get('wartist')};
1191
+ }
1192
+
1193
+ /**
1194
+ * Resolve a share target to a loadable track. Prefers an on-page trigger
1195
+ * (matched by `data-wb-id`, then by url) so the cold load inherits the
1196
+ * page's pre-generated peaks, markers, and favorite/cart state; falls back
1197
+ * to a minimal track built from the embedded url + title/artist so the link
1198
+ * still works on a page that doesn't contain the track.
1199
+ * @param {{id:string|null, url:string|null, title:string|null, artist:string|null}} target
1200
+ * @returns {Object|null}
1201
+ * @private
1202
+ */
1203
+ _resolveSharedTrack(target) {
1204
+ const triggers = document.querySelectorAll('[data-wb-play], [data-wb-queue]');
1205
+
1206
+ // 1. By id (clean, short links). Skips when no id was shared.
1207
+ if (target.id) {
1208
+ for (const el of triggers) {
1209
+ if (el.dataset.wbId === target.id || el.dataset.id === target.id) {
1210
+ const t = parseTrackFromElement(el);
1211
+ if (t) return t;
1212
+ }
1213
+ }
1214
+ }
1215
+
1216
+ // 2. By url — match an on-page trigger to inherit its peaks/state...
1217
+ if (target.url) {
1218
+ for (const el of triggers) {
1219
+ if (el.dataset.wbUrl === target.url || el.dataset.url === target.url) {
1220
+ const t = parseTrackFromElement(el);
1221
+ if (t) return t;
1222
+ }
1223
+ }
1224
+ // 3. ...else build a minimal track straight from the link (cross-page).
1225
+ return {
1226
+ url: target.url,
1227
+ id: target.id || target.url,
1228
+ title: target.title || extractTitle(target.url),
1229
+ artist: target.artist || ''
1230
+ };
1231
+ }
1232
+
1233
+ return null;
1234
+ }
1235
+
1236
+ /**
1237
+ * Cold-load a share-target track at a timestamp, paused. Mirrors the
1238
+ * restore path (loadTrack with autoplay:false + a `_loadSeq`-guarded,
1239
+ * delayed seek) so a later user action cleanly supersedes it.
1240
+ * @param {Object} track
1241
+ * @param {number} time - seconds to seek to once loaded
1242
+ * @private
1243
+ */
1244
+ _loadSharedTrack(track, time) {
1245
+ if (!track || !track.url || !this.player) return;
1246
+
1247
+ // Make it the current queue entry (dedup by url, like play()).
1248
+ const existing = this.queue.findIndex(t => t.url === track.url);
1249
+ if (existing >= 0) {
1250
+ this.queue[existing] = {...this.queue[existing], ...track};
1251
+ this.currentIndex = existing;
1252
+ } else {
1253
+ this.queue.push(track);
1254
+ this.currentIndex = this.queue.length - 1;
1255
+ }
1256
+
1257
+ this.show();
1258
+ this._updateTrackDisplay(track);
1259
+ this._updateFavoriteUI();
1260
+ this._updateNavButtons();
1261
+
1262
+ const loadOpts = {autoplay: false};
1263
+ if (track.waveform) loadOpts.waveform = track.waveform;
1264
+ if (track.markers && track.markers.length) {
1265
+ const defaultColor = this.config.markerColor;
1266
+ loadOpts.markers = track.markers.map(m => ({...m, color: m.color || defaultColor}));
1267
+ this._activeMarkers = track.markers;
1268
+ } else {
1269
+ loadOpts.markers = [];
1270
+ this._activeMarkers = null;
1271
+ }
1272
+ this._currentMarkerIndex = -1;
1273
+
1274
+ const seq = ++this._loadSeq;
1275
+ if (this._restoreSeekTimeout) {
1276
+ clearTimeout(this._restoreSeekTimeout);
1277
+ this._restoreSeekTimeout = null;
1278
+ }
1279
+
1280
+ this.player.loadTrack(track.url, track.title, track.artist, loadOpts).then(() => {
1281
+ if (this._loadSeq !== seq) return;
1282
+ if (this.player) this.player.setVolume(this.isMuted ? 0 : this.volume);
1283
+ // Seek slightly after load — the audio element needs to be ready
1284
+ // for the seek to stick (same 100ms guard the restore path uses).
1285
+ if (time > 0) {
1286
+ this._restoreSeekTimeout = setTimeout(() => {
1287
+ this._restoreSeekTimeout = null;
1288
+ if (this._loadSeq !== seq) return;
1289
+ if (this.player) {
1290
+ this.player.seekTo(time);
1291
+ this._lastPosition = time;
1292
+ }
1293
+ }, 100);
1294
+ }
1295
+ }).catch(() => {});
1296
+
1297
+ this._renderQueue();
1298
+ this._syncPageState();
1299
+ this._saveState();
1300
+ this._updateNavButtons();
1301
+ this._emit('trackchange', {track, index: this.currentIndex});
1302
+ if (this.config.onTrackChange) this.config.onTrackChange(track, this.currentIndex);
1303
+ }
1304
+
1305
+ /**
1306
+ * Copy a shareable link to the current track at the current position, use
1307
+ * the native share sheet when available, and emit `waveformbar:share`. The
1308
+ * link carries both the timestamp AND the track identity so a cold open
1309
+ * loads the right audio: `?<shareParam>=<seconds>` plus `wid` (id, when the
1310
+ * track has a real one), `wu` (url — the works-anywhere fallback), and
1311
+ * `wtitle`/`wartist` for display before metadata loads.
1312
+ * @private
1313
+ */
1314
+ _share() {
1315
+ const track = this.getCurrentTrack();
1316
+ const cur = this.player && this.player.audio ? this.player.audio.currentTime : 0;
1317
+ const seconds = Math.max(0, Math.floor(cur || 0));
1318
+ let link;
1319
+ try {
1320
+ const url = new URL(window.location.href);
1321
+ const p = url.searchParams;
1322
+ p.set(this.config.shareParam, String(seconds));
1323
+ if (track) {
1324
+ // id falls back to url internally, so only emit wid when it's a
1325
+ // real, distinct identifier (clean per-track-page links).
1326
+ if (track.id && track.id !== track.url) p.set('wid', track.id);
1327
+ if (track.url) p.set('wu', track.url);
1328
+ if (track.title) p.set('wtitle', track.title);
1329
+ if (track.artist) p.set('wartist', track.artist);
1330
+ }
1331
+ link = url.toString();
1332
+ } catch (e) {
1333
+ return;
1334
+ }
1335
+ if (navigator.clipboard && navigator.clipboard.writeText) {
1336
+ navigator.clipboard.writeText(link).catch(() => {});
1337
+ }
1338
+ if (navigator.share) {
1339
+ navigator.share({title: (track && track.title) || undefined, url: link}).catch(() => {});
1340
+ }
1341
+ this._flashShareCopied();
1342
+ this._emit('share', {url: link, time: seconds, track});
1343
+ }
1344
+
1345
+ /**
1346
+ * Briefly flag the share button as "copied" for visual feedback.
1347
+ * @private
1348
+ */
1349
+ _flashShareCopied() {
1350
+ if (!this.shareBtnEl) return;
1351
+ this.shareBtnEl.classList.add('wb-copied');
1352
+ this.shareBtnEl.setAttribute('title', 'Link copied!');
1353
+ clearTimeout(this._shareFlashTimeout);
1354
+ this._shareFlashTimeout = setTimeout(() => {
1355
+ if (this.shareBtnEl) {
1356
+ this.shareBtnEl.classList.remove('wb-copied');
1357
+ this.shareBtnEl.setAttribute('title', 'Copy share link');
1358
+ }
1359
+ }, 1500);
1360
+ }
1361
+
1362
+ /**
1363
+ * Collapse the bar to a small floating pill (artwork + play + expand).
1364
+ * @returns {WaveformBar}
1365
+ */
1366
+ collapse() {
1367
+ this.isCollapsed = true;
1368
+ if (this.barEl) this.barEl.classList.add('wb-collapsed');
1369
+ this._updateCollapseButton();
1370
+ this._saveCollapsed();
1371
+ this._emit('collapse', {collapsed: true});
1372
+ return this;
1373
+ }
1374
+
1375
+ /**
1376
+ * Restore the bar from its collapsed pill back to the full bar.
1377
+ * @returns {WaveformBar}
1378
+ */
1379
+ expand() {
1380
+ this.isCollapsed = false;
1381
+ if (this.barEl) this.barEl.classList.remove('wb-collapsed');
1382
+ this._updateCollapseButton();
1383
+ this._saveCollapsed();
1384
+ this._emit('collapse', {collapsed: false});
1385
+ return this;
1386
+ }
1387
+
1388
+ /**
1389
+ * Toggle the collapsed pill state.
1390
+ * @returns {WaveformBar}
1391
+ */
1392
+ toggleCollapse() {
1393
+ return this.isCollapsed ? this.expand() : this.collapse();
1394
+ }
1395
+
1396
+ /**
1397
+ * Swap the collapse button's icon + labels for the current state.
1398
+ * @private
1399
+ */
1400
+ _updateCollapseButton() {
1401
+ if (!this.collapseBtnEl) return;
1402
+ this.collapseBtnEl.innerHTML = this.isCollapsed ? ICONS.expand : ICONS.collapse;
1403
+ const label = this.isCollapsed ? 'Expand' : 'Collapse';
1404
+ this.collapseBtnEl.setAttribute('aria-label', label);
1405
+ this.collapseBtnEl.setAttribute('title', label);
1406
+ }
1407
+
1408
+ /** Persist the collapsed state (session-scoped) when persistence is on. @private */
1409
+ _saveCollapsed() {
1410
+ if (!this.config.persist) return;
1411
+ try {
1412
+ sessionStorage.setItem(this.config.storageKey + '-collapsed', this.isCollapsed ? '1' : '0');
1413
+ } catch (e) {}
1414
+ }
1415
+
1416
+ /** Read the persisted collapsed state. @returns {boolean} @private */
1417
+ _readCollapsed() {
1418
+ if (!this.config.persist) return false;
1419
+ try {
1420
+ return sessionStorage.getItem(this.config.storageKey + '-collapsed') === '1';
1421
+ } catch (e) {
1422
+ return false;
1423
+ }
1424
+ }
1425
+
976
1426
  _loadCurrentTrack() {
977
1427
  const track = this.getCurrentTrack();
978
1428
  if (!track || !this.player) return;
979
1429
 
1430
+ // Bump the load token and cancel any pending restore-seek so a
1431
+ // stale async continuation (from a prior load) can't apply to this
1432
+ // newer track under rapid switching.
1433
+ this._loadSeq++;
1434
+ if (this._restoreSeekTimeout) {
1435
+ clearTimeout(this._restoreSeekTimeout);
1436
+ this._restoreSeekTimeout = null;
1437
+ }
1438
+
980
1439
  // Reset any previously-current external player so its UI flips
981
1440
  // back to "paused" while the new track loads. The new track's
982
1441
  // onPlay callback will set the matching external to playing
@@ -1188,7 +1647,7 @@ export class WaveformBar {
1188
1647
  // Update artwork if provided
1189
1648
  if (marker.artwork) {
1190
1649
  const artworkEl = this.barEl.querySelector('.wb-artwork');
1191
- if (artworkEl) artworkEl.innerHTML = `<img src="${marker.artwork}" alt="${marker.title || ''}" />`;
1650
+ if (artworkEl) artworkEl.innerHTML = `<img src="${escapeHtml(marker.artwork)}" alt="${escapeHtml(marker.title || '')}" />`;
1192
1651
  }
1193
1652
 
1194
1653
  // Update meta tags if bpm/key provided
@@ -1411,30 +1870,38 @@ export class WaveformBar {
1411
1870
  this._updateFavoriteUI();
1412
1871
  this._updateNavButtons();
1413
1872
 
1414
- // Use load() instead of loadTrack() to avoid auto-play.
1415
- // We handle seek and play manually after the audio is ready.
1416
- if (track.waveform) {
1417
- this.player.options.waveform = track.waveform;
1418
- }
1419
-
1420
- this.player.options.title = track.title || '';
1421
- this.player.options.subtitle = track.artist || '';
1873
+ // Use the public loadTrack() API with { autoplay: false } (core
1874
+ // v1.8.0+) instead of poking player.options.* + load(). This avoids
1875
+ // loadTrack's forced auto-play while still routing through the
1876
+ // supported API — we handle seek and resume manually below.
1877
+ const loadOpts = {autoplay: false};
1878
+ if (track.waveform) loadOpts.waveform = track.waveform;
1422
1879
 
1423
1880
  // Pass markers to the player and set up DJ mode
1424
1881
  if (track.markers && track.markers.length) {
1425
1882
  const defaultColor = this.config.markerColor;
1426
- this.player.options.markers = track.markers.map(m => ({
1883
+ loadOpts.markers = track.markers.map(m => ({
1427
1884
  ...m,
1428
1885
  color: m.color || defaultColor
1429
1886
  }));
1430
1887
  this._activeMarkers = track.markers;
1431
1888
  } else {
1432
- this.player.options.markers = [];
1889
+ loadOpts.markers = [];
1433
1890
  this._activeMarkers = null;
1434
1891
  }
1435
1892
  this._currentMarkerIndex = -1;
1436
1893
 
1437
- this.player.load(track.url).then(() => {
1894
+ // Capture the load token so the async continuation below bails out if
1895
+ // a newer load (user clicked another track, or destroy()) supersedes
1896
+ // this restore before its promise / seek-timeout fires.
1897
+ const seq = ++this._loadSeq;
1898
+ if (this._restoreSeekTimeout) {
1899
+ clearTimeout(this._restoreSeekTimeout);
1900
+ this._restoreSeekTimeout = null;
1901
+ }
1902
+
1903
+ this.player.loadTrack(track.url, track.title, track.artist, loadOpts).then(() => {
1904
+ if (this._loadSeq !== seq) return;
1438
1905
  if (this.player) this.player.setVolume(this.isMuted ? 0 : this.volume);
1439
1906
 
1440
1907
  if (state.isPlaying && this.config.autoResume) {
@@ -1457,7 +1924,9 @@ export class WaveformBar {
1457
1924
  // Seek after play — the audio element needs to be in a playing
1458
1925
  // or ready state for seek to stick reliably
1459
1926
  if (state.position > 0) {
1460
- setTimeout(() => {
1927
+ this._restoreSeekTimeout = setTimeout(() => {
1928
+ this._restoreSeekTimeout = null;
1929
+ if (this._loadSeq !== seq) return;
1461
1930
  if (this.player) {
1462
1931
  this.player.seekTo(state.position);
1463
1932
  this._lastPosition = state.position;
@@ -1474,7 +1943,10 @@ export class WaveformBar {
1474
1943
  _restoreVolume() {
1475
1944
  const data = restoreVolume(this.config.storageKey);
1476
1945
  if (!data) return;
1477
- this.volume = data.volume;
1946
+ // Normalize persisted volume a corrupted store could otherwise
1947
+ // carry NaN / out-of-range and mute or break the player.
1948
+ const v = Number(data.volume);
1949
+ this.volume = Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 1;
1478
1950
  this.isMuted = data.muted;
1479
1951
  this._volumeBeforeMute = data.volumeBeforeMute;
1480
1952
  if (this.player) this.player.setVolume(this.isMuted ? 0 : this.volume);