@arraypress/waveform-bar 1.3.2 → 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.
@@ -5,7 +5,10 @@ var ICONS = {
5
5
  prev: '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>',
6
6
  next: '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>',
7
7
  queue: '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M3 13h2v-2H3v2zm0 4h2v-2H3v2zm0-8h2V7H3v2zm4 4h14v-2H7v2zm0 4h14v-2H7v2zM7 7v2h14V7H7z"/></svg>',
8
+ share: '<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/><polyline points="16 6 12 2 8 6"/><line x1="12" y1="2" x2="12" y2="15"/></svg>',
8
9
  music: '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" opacity="0.5"><path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55C7.79 13 6 14.79 6 17s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/></svg>',
10
+ collapse: '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>',
11
+ expand: '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg>',
9
12
  volHigh: '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/></svg>',
10
13
  volLow: '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M18.5 12c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM5 9v6h4l5 5V4L9 9H5z"/></svg>',
11
14
  volMute: '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/></svg>',
@@ -30,6 +33,15 @@ function escapeHtml(str) {
30
33
  d.textContent = str;
31
34
  return d.innerHTML;
32
35
  }
36
+ function isSafeHref(url) {
37
+ if (typeof url !== "string" || url === "") return false;
38
+ try {
39
+ const u = new URL(url, location.href);
40
+ return u.protocol === "http:" || u.protocol === "https:";
41
+ } catch (e) {
42
+ return false;
43
+ }
44
+ }
33
45
  function formatTime(seconds) {
34
46
  if (!seconds || isNaN(seconds)) return "0:00";
35
47
  const m = Math.floor(seconds / 60);
@@ -41,12 +53,21 @@ function parseTrackFromElement(el) {
41
53
  if (!url) return null;
42
54
  let meta = {};
43
55
  try {
44
- meta = JSON.parse(el.dataset.wbMeta || el.dataset.meta || "{}");
56
+ const parsed = JSON.parse(el.dataset.wbMeta || el.dataset.meta || "{}");
57
+ meta = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
58
+ } catch (e) {
59
+ }
60
+ let markers = [];
61
+ try {
62
+ const parsed = JSON.parse(el.dataset.wbMarkers || el.dataset.markers || "null");
63
+ markers = Array.isArray(parsed) ? parsed : [];
45
64
  } catch (e) {
46
65
  }
47
- let markers = null;
66
+ markers = markers.map((m) => m && typeof m === "object" ? { ...m, time: Number(m.time) } : null).filter((m) => m && Number.isFinite(m.time));
67
+ let waveform = null;
48
68
  try {
49
- markers = JSON.parse(el.dataset.wbMarkers || el.dataset.markers || "null");
69
+ const parsed = JSON.parse(el.dataset.wbWaveform || el.dataset.waveform || "null");
70
+ waveform = Array.isArray(parsed) ? parsed : null;
50
71
  } catch (e) {
51
72
  }
52
73
  return {
@@ -60,7 +81,7 @@ function parseTrackFromElement(el) {
60
81
  duration: el.dataset.wbDuration || el.dataset.duration || "",
61
82
  bpm: el.dataset.wbBpm || el.dataset.bpm || "",
62
83
  key: el.dataset.wbKey || el.dataset.key || "",
63
- waveform: el.dataset.wbWaveform || el.dataset.waveform || "",
84
+ waveform,
64
85
  markers,
65
86
  favorited: el.dataset.wbFavorited === "true",
66
87
  inCart: el.dataset.wbInCart === "true",
@@ -132,7 +153,7 @@ function fireAction(actionConfig, payload) {
132
153
  try {
133
154
  actionConfig.endpoint(payload);
134
155
  } catch (err) {
135
- console.warn("WaveformBar action callback error:", err);
156
+ console.warn("[WaveformBar] Action callback error:", err);
136
157
  }
137
158
  return;
138
159
  }
@@ -144,7 +165,7 @@ function fireAction(actionConfig, payload) {
144
165
  ...actionConfig.headers || {}
145
166
  },
146
167
  body: JSON.stringify(payload)
147
- }).catch((err) => console.warn("WaveformBar action request failed:", err));
168
+ }).catch((err) => console.warn("[WaveformBar] Action request failed:", err));
148
169
  }
149
170
  }
150
171
 
@@ -202,11 +223,15 @@ function buildBarHTML(config) {
202
223
  }
203
224
  right += "</div>";
204
225
  }
226
+ if (config.share) {
227
+ right += `<button class="wb-btn wb-btn-sm wb-share" aria-label="Share" title="Copy share link">${ICONS.share}</button>`;
228
+ }
205
229
  if (config.showQueue) {
206
230
  right += `<button class="wb-btn wb-btn-sm wb-queue-btn" aria-label="Queue" title="Queue">${ICONS.queue}</button>`;
207
231
  }
208
232
  right += "</div>";
209
- return `<div class="wb-inner">${left}${centre}${right}</div>`;
233
+ const collapse = config.collapsible ? `<button class="wb-btn wb-btn-sm wb-collapse" aria-label="Collapse" title="Collapse">${ICONS.collapse}</button>` : "";
234
+ return `<div class="wb-inner">${left}${centre}${right}${collapse}</div>`;
210
235
  }
211
236
 
212
237
  // src/js/queue.js
@@ -310,10 +335,27 @@ var DEFAULTS = {
310
335
  // URL to fallback artwork image
311
336
  theme: null,
312
337
  // 'dark', 'light', or null (dark by default)
338
+ wide: false,
339
+ // true = content spans full width (lifts the 1400px cap)
340
+ maxWidth: null,
341
+ // custom content max-width (CSS value), e.g. '1200px'; overrides `wide`
342
+ position: "bottom",
343
+ // 'bottom' (default) or 'top' — which edge the bar docks to
344
+ collapsible: false,
345
+ // show a collapse button that shrinks the bar to a floating transport pill
346
+ waveform: true,
347
+ // false = classic Spotify-style seek bar instead of the waveform
348
+ errorText: null,
349
+ // custom "audio failed to load" message (null = player default)
350
+ share: false,
351
+ // show a "copy share link" button (emits ?<shareParam>=<seconds>)
352
+ shareParam: "wt",
353
+ // URL query param for the shared timestamp (seconds)
313
354
  waveformStyle: "mirror",
314
355
  waveformHeight: 32,
315
356
  barWidth: 2,
316
- barSpacing: 0,
357
+ barSpacing: 2,
358
+ // 2px gap between 2px bars — crisp, separated bars (0 = solid "blob")
317
359
  waveformColor: null,
318
360
  progressColor: null,
319
361
  markerColor: "rgba(255, 255, 255, 0.25)",
@@ -347,6 +389,9 @@ var WaveformBar = class {
347
389
  this._activeMarkers = null;
348
390
  this._currentMarkerIndex = -1;
349
391
  this.repeat = "off";
392
+ this._loadSeq = 0;
393
+ this._restoreSeekTimeout = null;
394
+ this._externalPlayers = /* @__PURE__ */ new Map();
350
395
  this.barEl = null;
351
396
  this.queueEl = null;
352
397
  this.waveformContainer = null;
@@ -377,9 +422,12 @@ var WaveformBar = class {
377
422
  init(config = {}) {
378
423
  if (this.isInitialized) this.destroy();
379
424
  this.config = { ...DEFAULTS, ...config };
380
- this.volume = this.config.volume;
425
+ const v = Number(this.config.volume);
426
+ this.volume = Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 1;
427
+ this._shareTarget = this._readShareTarget();
428
+ this._shareSeek = this._shareTarget && !this._shareTarget.id && !this._shareTarget.url ? this._shareTarget.time : null;
381
429
  if (typeof window.WaveformPlayer === "undefined") {
382
- console.error("WaveformBar: WaveformPlayer is required.");
430
+ console.error("[WaveformBar] WaveformPlayer is required.");
383
431
  return this;
384
432
  }
385
433
  this._createBar();
@@ -395,6 +443,10 @@ var WaveformBar = class {
395
443
  if (this.config.persist) {
396
444
  this._restoreState();
397
445
  }
446
+ if (this._shareTarget && (this._shareTarget.id || this._shareTarget.url)) {
447
+ const shared = this._resolveSharedTrack(this._shareTarget);
448
+ if (shared) this._loadSharedTrack(shared, this._shareTarget.time);
449
+ }
398
450
  this.isInitialized = true;
399
451
  this._beforeUnloadHandler = () => this._saveState();
400
452
  window.addEventListener("beforeunload", this._beforeUnloadHandler);
@@ -409,6 +461,37 @@ var WaveformBar = class {
409
461
  this.player.destroy();
410
462
  this.player = null;
411
463
  }
464
+ if (this._docClickVolume) {
465
+ document.removeEventListener("click", this._docClickVolume);
466
+ this._docClickVolume = null;
467
+ }
468
+ if (this._docClickQueue) {
469
+ document.removeEventListener("click", this._docClickQueue);
470
+ this._docClickQueue = null;
471
+ }
472
+ if (this._docClickTriggers) {
473
+ document.removeEventListener("click", this._docClickTriggers);
474
+ this._docClickTriggers = null;
475
+ }
476
+ if (this._externalListenersBound) {
477
+ document.removeEventListener("waveformplayer:request-play", this._onExtRequestPlay);
478
+ document.removeEventListener("waveformplayer:request-pause", this._onExtRequestPause);
479
+ document.removeEventListener("waveformplayer:request-seek", this._onExtRequestSeek);
480
+ document.removeEventListener("waveformplayer:destroy", this._onExtDestroy);
481
+ this._onExtRequestPlay = null;
482
+ this._onExtRequestPause = null;
483
+ this._onExtRequestSeek = null;
484
+ this._onExtDestroy = null;
485
+ this._externalListenersBound = false;
486
+ }
487
+ this._externalPlayers = /* @__PURE__ */ new Map();
488
+ if (this._restoreSeekTimeout) {
489
+ clearTimeout(this._restoreSeekTimeout);
490
+ this._restoreSeekTimeout = null;
491
+ }
492
+ clearTimeout(this._shareFlashTimeout);
493
+ this._shareFlashTimeout = null;
494
+ this._loadSeq++;
412
495
  if (this.barEl) {
413
496
  this.barEl.remove();
414
497
  this.barEl = null;
@@ -425,7 +508,22 @@ var WaveformBar = class {
425
508
  window.removeEventListener("beforeunload", this._beforeUnloadHandler);
426
509
  this._beforeUnloadHandler = null;
427
510
  }
428
- document.querySelectorAll("[data-wb-play],[data-wb-queue]").forEach((el) => delete el._wbBound);
511
+ this.volumePopupEl = null;
512
+ this.queueBtnEl = null;
513
+ this.titleEl = null;
514
+ this.artistEl = null;
515
+ this.metaEl = null;
516
+ this.playBtnEl = null;
517
+ this.repeatBtnEl = null;
518
+ this.waveformContainer = null;
519
+ this.queueBodyEl = null;
520
+ this.queueCountEl = null;
521
+ this.muteBtnEl = null;
522
+ this.volumeSliderEl = null;
523
+ this.favBtnEl = null;
524
+ this.cartBtnEl = null;
525
+ this.timeCurrentEl = null;
526
+ this.timeTotalEl = null;
429
527
  document.querySelectorAll(".wb-current,.wb-playing").forEach((el) => el.classList.remove("wb-current", "wb-playing"));
430
528
  this.queue = [];
431
529
  this.currentIndex = -1;
@@ -444,6 +542,9 @@ var WaveformBar = class {
444
542
  const theme = this.config.theme || this._detectTheme();
445
543
  if (theme === "light") this.barEl.classList.add("wb-light");
446
544
  this._resolvedTheme = theme;
545
+ const maxWidth = this.config.maxWidth || (this.config.wide ? "100%" : null);
546
+ if (maxWidth) this.barEl.style.setProperty("--wb-max-width", maxWidth);
547
+ if (this.config.position === "top") this.barEl.classList.add("wb-top");
447
548
  this.barEl.id = "waveform-bar";
448
549
  this.barEl.innerHTML = buildBarHTML(this.config);
449
550
  document.body.appendChild(this.barEl);
@@ -453,17 +554,24 @@ var WaveformBar = class {
453
554
  this.playBtnEl = this.barEl.querySelector(".wb-play");
454
555
  this.waveformContainer = this.barEl.querySelector(".wb-waveform-container");
455
556
  this.queueBtnEl = this.barEl.querySelector(".wb-queue-btn");
557
+ this.shareBtnEl = this.barEl.querySelector(".wb-share");
456
558
  this.muteBtnEl = this.barEl.querySelector(".wb-mute");
457
559
  this.volumeSliderEl = this.barEl.querySelector(".wb-volume-slider");
458
560
  this.favBtnEl = this.barEl.querySelector(".wb-fav");
459
561
  this.cartBtnEl = this.barEl.querySelector(".wb-cart");
460
562
  this.timeCurrentEl = this.barEl.querySelector(".wb-time-current");
461
563
  this.timeTotalEl = this.barEl.querySelector(".wb-time-total");
564
+ this.collapseBtnEl = this.barEl.querySelector(".wb-collapse");
462
565
  this.playBtnEl.addEventListener("click", () => this.togglePlay());
566
+ if (this.collapseBtnEl) {
567
+ this.collapseBtnEl.addEventListener("click", () => this.toggleCollapse());
568
+ if (this._readCollapsed()) this.collapse();
569
+ }
463
570
  const prevBtn = this.barEl.querySelector(".wb-prev");
464
571
  const nextBtn = this.barEl.querySelector(".wb-next");
465
572
  if (prevBtn) prevBtn.addEventListener("click", () => this.previous());
466
573
  if (nextBtn) nextBtn.addEventListener("click", () => this.next());
574
+ if (this.shareBtnEl) this.shareBtnEl.addEventListener("click", () => this._share());
467
575
  this.repeatBtnEl = this.barEl.querySelector(".wb-repeat");
468
576
  if (this.repeatBtnEl) {
469
577
  this.repeat = this.config.repeat || "off";
@@ -495,17 +603,18 @@ var WaveformBar = class {
495
603
  this.setVolume(parseInt(e.target.value) / 100);
496
604
  });
497
605
  }
498
- document.addEventListener("click", (e) => {
499
- if (this.volumePopupEl?.classList.contains("wb-volume-open") && !this.barEl.querySelector(".wb-volume")?.contains(e.target)) {
606
+ this._docClickVolume = (e) => {
607
+ if (this.volumePopupEl?.classList.contains("wb-volume-open") && !this.barEl?.querySelector(".wb-volume")?.contains(e.target)) {
500
608
  this.closeVolumePopup();
501
609
  }
502
- });
610
+ };
611
+ document.addEventListener("click", this._docClickVolume);
503
612
  if (this.favBtnEl) this.favBtnEl.addEventListener("click", () => this.toggleFavorite());
504
613
  if (this.cartBtnEl) this.cartBtnEl.addEventListener("click", () => this.addToCart());
505
614
  if (this.config.showTrackLink) {
506
615
  this.barEl.querySelector(".wb-track").addEventListener("click", () => {
507
616
  const t = this.getCurrentTrack();
508
- if (t && t.link) window.location.href = t.link;
617
+ if (t && t.link && isSafeHref(t.link)) window.location.href = t.link;
509
618
  });
510
619
  }
511
620
  }
@@ -517,20 +626,26 @@ var WaveformBar = class {
517
626
  this.queueBodyEl = this.queueEl.querySelector(".wb-queue-body");
518
627
  this.queueCountEl = this.queueEl.querySelector(".wb-queue-count");
519
628
  this.queueEl.querySelector(".wb-queue-clear").addEventListener("click", () => this.clearQueue());
520
- document.addEventListener("click", (e) => {
521
- if (this.queueOpen && !this.queueEl.contains(e.target) && !this.queueBtnEl.contains(e.target)) {
629
+ this._docClickQueue = (e) => {
630
+ if (this.queueOpen && !this.queueEl?.contains(e.target) && !this.queueBtnEl?.contains(e.target)) {
522
631
  this.closeQueuePanel();
523
632
  }
524
- });
633
+ };
634
+ document.addEventListener("click", this._docClickQueue);
525
635
  }
526
636
  _initPlayer() {
527
637
  const opts = {
528
638
  showControls: false,
529
639
  showInfo: false,
530
- waveformStyle: this.config.waveformStyle,
640
+ // Classic mode reuses the player's own built-in 'seekbar' style —
641
+ // a simple rounded progress bar (no waveform), with the player's
642
+ // native click-to-seek. No custom seek-bar DOM needed.
643
+ waveformStyle: this.config.waveform === false ? "seekbar" : this.config.waveformStyle,
531
644
  height: this.config.waveformHeight,
532
645
  barWidth: this.config.barWidth,
533
646
  barSpacing: this.config.barSpacing,
647
+ errorText: this.config.errorText,
648
+ // null -> player uses its own default
534
649
  singlePlay: false,
535
650
  onPlay: () => {
536
651
  this.isPlaying = true;
@@ -573,6 +688,18 @@ var WaveformBar = class {
573
688
  this._loadCurrentTrack();
574
689
  }
575
690
  },
691
+ onError: () => {
692
+ this.isPlaying = false;
693
+ this._updatePlayButton();
694
+ this._syncPageState();
695
+ this._pumpExternalPlayState(false);
696
+ const track = this.getCurrentTrack();
697
+ this._emit("error", { track });
698
+ if (this.config.continuous && this.currentIndex < this.queue.length - 1) {
699
+ this.currentIndex++;
700
+ this._loadCurrentTrack();
701
+ }
702
+ },
576
703
  onTimeUpdate: (currentTime, duration) => {
577
704
  this._lastPosition = currentTime;
578
705
  if (this.timeCurrentEl) this.timeCurrentEl.textContent = formatTime(currentTime);
@@ -586,7 +713,12 @@ var WaveformBar = class {
586
713
  this._checkMarkerBoundary(currentTime);
587
714
  }
588
715
  },
589
- onLoad: null
716
+ onLoad: () => {
717
+ if (this._shareSeek != null && this.player) {
718
+ this.player.seekTo(this._shareSeek);
719
+ this._shareSeek = null;
720
+ }
721
+ }
590
722
  };
591
723
  if (this.config.waveformColor) opts.waveformColor = this.config.waveformColor;
592
724
  if (this.config.progressColor) opts.progressColor = this.config.progressColor;
@@ -597,25 +729,24 @@ var WaveformBar = class {
597
729
  // Triggers (private)
598
730
  // =====================================================================
599
731
  _bindTriggers() {
600
- document.querySelectorAll("[data-wb-play]").forEach((el) => {
601
- if (el._wbBound) return;
602
- el._wbBound = true;
603
- el.addEventListener("click", (e) => {
604
- e.preventDefault();
605
- const track = parseTrackFromElement(el);
606
- if (track) this.play(track);
607
- });
608
- });
609
- document.querySelectorAll("[data-wb-queue]").forEach((el) => {
610
- if (el._wbBound) return;
611
- el._wbBound = true;
612
- el.addEventListener("click", (e) => {
613
- e.preventDefault();
614
- e.stopPropagation();
615
- const track = parseTrackFromElement(el);
616
- if (track) this.addToQueue(track);
617
- });
618
- });
732
+ if (!this._docClickTriggers) {
733
+ this._docClickTriggers = (e) => {
734
+ const queueEl = e.target?.closest?.("[data-wb-queue]");
735
+ if (queueEl) {
736
+ e.preventDefault();
737
+ const track = parseTrackFromElement(queueEl);
738
+ if (track) this.addToQueue(track);
739
+ return;
740
+ }
741
+ const playEl = e.target?.closest?.("[data-wb-play]");
742
+ if (playEl) {
743
+ e.preventDefault();
744
+ const track = parseTrackFromElement(playEl);
745
+ if (track) this.play(track);
746
+ }
747
+ };
748
+ document.addEventListener("click", this._docClickTriggers);
749
+ }
619
750
  this._attachExternalPlayers();
620
751
  }
621
752
  /**
@@ -633,13 +764,13 @@ var WaveformBar = class {
633
764
  _attachExternalPlayers() {
634
765
  if (!this._externalListenersBound) {
635
766
  this._externalListenersBound = true;
636
- document.addEventListener("waveformplayer:request-play", (e) => {
767
+ this._onExtRequestPlay = (e) => {
637
768
  const t = e.detail;
638
769
  if (!t || !t.url) return;
639
770
  e.preventDefault();
640
771
  this.play(t);
641
- });
642
- document.addEventListener("waveformplayer:request-pause", (e) => {
772
+ };
773
+ this._onExtRequestPause = (e) => {
643
774
  const t = e.detail;
644
775
  if (!t || !t.url) return;
645
776
  const current = this.getCurrentTrack();
@@ -647,8 +778,8 @@ var WaveformBar = class {
647
778
  e.preventDefault();
648
779
  if (this.isPlaying) this.togglePlay();
649
780
  }
650
- });
651
- document.addEventListener("waveformplayer:request-seek", (e) => {
781
+ };
782
+ this._onExtRequestSeek = (e) => {
652
783
  const t = e.detail;
653
784
  if (!t || !t.url || typeof t.percent !== "number") return;
654
785
  const current = this.getCurrentTrack();
@@ -656,7 +787,16 @@ var WaveformBar = class {
656
787
  e.preventDefault();
657
788
  this.player.seekToPercent(t.percent);
658
789
  }
659
- });
790
+ };
791
+ this._onExtDestroy = (e) => {
792
+ const inst = e.detail && e.detail.player;
793
+ if (!inst || !this._externalPlayers) return;
794
+ this._externalPlayers.forEach((set) => set.delete(inst));
795
+ };
796
+ document.addEventListener("waveformplayer:request-play", this._onExtRequestPlay);
797
+ document.addEventListener("waveformplayer:request-pause", this._onExtRequestPause);
798
+ document.addEventListener("waveformplayer:request-seek", this._onExtRequestSeek);
799
+ document.addEventListener("waveformplayer:destroy", this._onExtDestroy);
660
800
  }
661
801
  const previous = this._externalPlayers || /* @__PURE__ */ new Map();
662
802
  this._externalPlayers = /* @__PURE__ */ new Map();
@@ -733,7 +873,7 @@ var WaveformBar = class {
733
873
  _observeDOM() {
734
874
  if (typeof MutationObserver === "undefined") return;
735
875
  this._observer = new MutationObserver(() => {
736
- this._bindTriggers();
876
+ this._attachExternalPlayers();
737
877
  this._syncPageState();
738
878
  });
739
879
  this._observer.observe(document.body, { childList: true, subtree: true });
@@ -880,6 +1020,7 @@ var WaveformBar = class {
880
1020
  this.isMuted = true;
881
1021
  if (this.player) this.player.setVolume(0);
882
1022
  this._updateVolumeUI();
1023
+ saveVolume(this.config.storageKey, this.volume, this.isMuted, this._volumeBeforeMute);
883
1024
  }
884
1025
  return this;
885
1026
  }
@@ -919,7 +1060,9 @@ var WaveformBar = class {
919
1060
  this._cartItems.add(id);
920
1061
  if (this.cartBtnEl) {
921
1062
  this.cartBtnEl.classList.add("wb-action-done");
922
- setTimeout(() => this.cartBtnEl.classList.remove("wb-action-done"), 1500);
1063
+ setTimeout(() => {
1064
+ if (this.cartBtnEl) this.cartBtnEl.classList.remove("wb-action-done");
1065
+ }, 1500);
923
1066
  }
924
1067
  this._syncCartAttributes(track.url, true);
925
1068
  this._emit("cart", { track });
@@ -1081,9 +1224,252 @@ var WaveformBar = class {
1081
1224
  // =====================================================================
1082
1225
  // Internal: Loading & Display
1083
1226
  // =====================================================================
1227
+ /**
1228
+ * Parse a share link from the URL: the timestamp (`?<shareParam>=`, seconds)
1229
+ * plus the track identity needed to load it cold — `?wid` (id, preferred),
1230
+ * `?wu` (url, the works-anywhere fallback), and `?wtitle`/`?wartist` for
1231
+ * display before metadata arrives. Returns null when no share params are
1232
+ * present. An unsafe `?wu` (javascript:/data: etc.) is dropped, not loaded.
1233
+ * @returns {{time:number, id:string|null, url:string|null, title:string|null, artist:string|null}|null}
1234
+ * @private
1235
+ */
1236
+ _readShareTarget() {
1237
+ let q;
1238
+ try {
1239
+ q = new URLSearchParams(window.location.search);
1240
+ } catch (e) {
1241
+ return null;
1242
+ }
1243
+ const rawTime = q.get(this.config.shareParam);
1244
+ const id = q.get("wid");
1245
+ const rawUrl = q.get("wu");
1246
+ if (rawTime == null && id == null && rawUrl == null) return null;
1247
+ let time = 0;
1248
+ if (rawTime != null) {
1249
+ const t = Number(rawTime);
1250
+ if (Number.isFinite(t) && t >= 0) time = t;
1251
+ }
1252
+ const url = rawUrl && isSafeHref(rawUrl) ? rawUrl : null;
1253
+ return { time, id: id || null, url, title: q.get("wtitle"), artist: q.get("wartist") };
1254
+ }
1255
+ /**
1256
+ * Resolve a share target to a loadable track. Prefers an on-page trigger
1257
+ * (matched by `data-wb-id`, then by url) so the cold load inherits the
1258
+ * page's pre-generated peaks, markers, and favorite/cart state; falls back
1259
+ * to a minimal track built from the embedded url + title/artist so the link
1260
+ * still works on a page that doesn't contain the track.
1261
+ * @param {{id:string|null, url:string|null, title:string|null, artist:string|null}} target
1262
+ * @returns {Object|null}
1263
+ * @private
1264
+ */
1265
+ _resolveSharedTrack(target) {
1266
+ const triggers = document.querySelectorAll("[data-wb-play], [data-wb-queue]");
1267
+ if (target.id) {
1268
+ for (const el of triggers) {
1269
+ if (el.dataset.wbId === target.id || el.dataset.id === target.id) {
1270
+ const t = parseTrackFromElement(el);
1271
+ if (t) return t;
1272
+ }
1273
+ }
1274
+ }
1275
+ if (target.url) {
1276
+ for (const el of triggers) {
1277
+ if (el.dataset.wbUrl === target.url || el.dataset.url === target.url) {
1278
+ const t = parseTrackFromElement(el);
1279
+ if (t) return t;
1280
+ }
1281
+ }
1282
+ return {
1283
+ url: target.url,
1284
+ id: target.id || target.url,
1285
+ title: target.title || extractTitle(target.url),
1286
+ artist: target.artist || ""
1287
+ };
1288
+ }
1289
+ return null;
1290
+ }
1291
+ /**
1292
+ * Cold-load a share-target track at a timestamp, paused. Mirrors the
1293
+ * restore path (loadTrack with autoplay:false + a `_loadSeq`-guarded,
1294
+ * delayed seek) so a later user action cleanly supersedes it.
1295
+ * @param {Object} track
1296
+ * @param {number} time - seconds to seek to once loaded
1297
+ * @private
1298
+ */
1299
+ _loadSharedTrack(track, time) {
1300
+ if (!track || !track.url || !this.player) return;
1301
+ const existing = this.queue.findIndex((t) => t.url === track.url);
1302
+ if (existing >= 0) {
1303
+ this.queue[existing] = { ...this.queue[existing], ...track };
1304
+ this.currentIndex = existing;
1305
+ } else {
1306
+ this.queue.push(track);
1307
+ this.currentIndex = this.queue.length - 1;
1308
+ }
1309
+ this.show();
1310
+ this._updateTrackDisplay(track);
1311
+ this._updateFavoriteUI();
1312
+ this._updateNavButtons();
1313
+ const loadOpts = { autoplay: false };
1314
+ if (track.waveform) loadOpts.waveform = track.waveform;
1315
+ if (track.markers && track.markers.length) {
1316
+ const defaultColor = this.config.markerColor;
1317
+ loadOpts.markers = track.markers.map((m) => ({ ...m, color: m.color || defaultColor }));
1318
+ this._activeMarkers = track.markers;
1319
+ } else {
1320
+ loadOpts.markers = [];
1321
+ this._activeMarkers = null;
1322
+ }
1323
+ this._currentMarkerIndex = -1;
1324
+ const seq = ++this._loadSeq;
1325
+ if (this._restoreSeekTimeout) {
1326
+ clearTimeout(this._restoreSeekTimeout);
1327
+ this._restoreSeekTimeout = null;
1328
+ }
1329
+ this.player.loadTrack(track.url, track.title, track.artist, loadOpts).then(() => {
1330
+ if (this._loadSeq !== seq) return;
1331
+ if (this.player) this.player.setVolume(this.isMuted ? 0 : this.volume);
1332
+ if (time > 0) {
1333
+ this._restoreSeekTimeout = setTimeout(() => {
1334
+ this._restoreSeekTimeout = null;
1335
+ if (this._loadSeq !== seq) return;
1336
+ if (this.player) {
1337
+ this.player.seekTo(time);
1338
+ this._lastPosition = time;
1339
+ }
1340
+ }, 100);
1341
+ }
1342
+ }).catch(() => {
1343
+ });
1344
+ this._renderQueue();
1345
+ this._syncPageState();
1346
+ this._saveState();
1347
+ this._updateNavButtons();
1348
+ this._emit("trackchange", { track, index: this.currentIndex });
1349
+ if (this.config.onTrackChange) this.config.onTrackChange(track, this.currentIndex);
1350
+ }
1351
+ /**
1352
+ * Copy a shareable link to the current track at the current position, use
1353
+ * the native share sheet when available, and emit `waveformbar:share`. The
1354
+ * link carries both the timestamp AND the track identity so a cold open
1355
+ * loads the right audio: `?<shareParam>=<seconds>` plus `wid` (id, when the
1356
+ * track has a real one), `wu` (url — the works-anywhere fallback), and
1357
+ * `wtitle`/`wartist` for display before metadata loads.
1358
+ * @private
1359
+ */
1360
+ _share() {
1361
+ const track = this.getCurrentTrack();
1362
+ const cur = this.player && this.player.audio ? this.player.audio.currentTime : 0;
1363
+ const seconds = Math.max(0, Math.floor(cur || 0));
1364
+ let link;
1365
+ try {
1366
+ const url = new URL(window.location.href);
1367
+ const p = url.searchParams;
1368
+ p.set(this.config.shareParam, String(seconds));
1369
+ if (track) {
1370
+ if (track.id && track.id !== track.url) p.set("wid", track.id);
1371
+ if (track.url) p.set("wu", track.url);
1372
+ if (track.title) p.set("wtitle", track.title);
1373
+ if (track.artist) p.set("wartist", track.artist);
1374
+ }
1375
+ link = url.toString();
1376
+ } catch (e) {
1377
+ return;
1378
+ }
1379
+ if (navigator.clipboard && navigator.clipboard.writeText) {
1380
+ navigator.clipboard.writeText(link).catch(() => {
1381
+ });
1382
+ }
1383
+ if (navigator.share) {
1384
+ navigator.share({ title: track && track.title || void 0, url: link }).catch(() => {
1385
+ });
1386
+ }
1387
+ this._flashShareCopied();
1388
+ this._emit("share", { url: link, time: seconds, track });
1389
+ }
1390
+ /**
1391
+ * Briefly flag the share button as "copied" for visual feedback.
1392
+ * @private
1393
+ */
1394
+ _flashShareCopied() {
1395
+ if (!this.shareBtnEl) return;
1396
+ this.shareBtnEl.classList.add("wb-copied");
1397
+ this.shareBtnEl.setAttribute("title", "Link copied!");
1398
+ clearTimeout(this._shareFlashTimeout);
1399
+ this._shareFlashTimeout = setTimeout(() => {
1400
+ if (this.shareBtnEl) {
1401
+ this.shareBtnEl.classList.remove("wb-copied");
1402
+ this.shareBtnEl.setAttribute("title", "Copy share link");
1403
+ }
1404
+ }, 1500);
1405
+ }
1406
+ /**
1407
+ * Collapse the bar to a small floating pill (artwork + play + expand).
1408
+ * @returns {WaveformBar}
1409
+ */
1410
+ collapse() {
1411
+ this.isCollapsed = true;
1412
+ if (this.barEl) this.barEl.classList.add("wb-collapsed");
1413
+ this._updateCollapseButton();
1414
+ this._saveCollapsed();
1415
+ this._emit("collapse", { collapsed: true });
1416
+ return this;
1417
+ }
1418
+ /**
1419
+ * Restore the bar from its collapsed pill back to the full bar.
1420
+ * @returns {WaveformBar}
1421
+ */
1422
+ expand() {
1423
+ this.isCollapsed = false;
1424
+ if (this.barEl) this.barEl.classList.remove("wb-collapsed");
1425
+ this._updateCollapseButton();
1426
+ this._saveCollapsed();
1427
+ this._emit("collapse", { collapsed: false });
1428
+ return this;
1429
+ }
1430
+ /**
1431
+ * Toggle the collapsed pill state.
1432
+ * @returns {WaveformBar}
1433
+ */
1434
+ toggleCollapse() {
1435
+ return this.isCollapsed ? this.expand() : this.collapse();
1436
+ }
1437
+ /**
1438
+ * Swap the collapse button's icon + labels for the current state.
1439
+ * @private
1440
+ */
1441
+ _updateCollapseButton() {
1442
+ if (!this.collapseBtnEl) return;
1443
+ this.collapseBtnEl.innerHTML = this.isCollapsed ? ICONS.expand : ICONS.collapse;
1444
+ const label = this.isCollapsed ? "Expand" : "Collapse";
1445
+ this.collapseBtnEl.setAttribute("aria-label", label);
1446
+ this.collapseBtnEl.setAttribute("title", label);
1447
+ }
1448
+ /** Persist the collapsed state (session-scoped) when persistence is on. @private */
1449
+ _saveCollapsed() {
1450
+ if (!this.config.persist) return;
1451
+ try {
1452
+ sessionStorage.setItem(this.config.storageKey + "-collapsed", this.isCollapsed ? "1" : "0");
1453
+ } catch (e) {
1454
+ }
1455
+ }
1456
+ /** Read the persisted collapsed state. @returns {boolean} @private */
1457
+ _readCollapsed() {
1458
+ if (!this.config.persist) return false;
1459
+ try {
1460
+ return sessionStorage.getItem(this.config.storageKey + "-collapsed") === "1";
1461
+ } catch (e) {
1462
+ return false;
1463
+ }
1464
+ }
1084
1465
  _loadCurrentTrack() {
1085
1466
  const track = this.getCurrentTrack();
1086
1467
  if (!track || !this.player) return;
1468
+ this._loadSeq++;
1469
+ if (this._restoreSeekTimeout) {
1470
+ clearTimeout(this._restoreSeekTimeout);
1471
+ this._restoreSeekTimeout = null;
1472
+ }
1087
1473
  this._pumpExternalPlayState(false);
1088
1474
  this.show();
1089
1475
  this._updateTrackDisplay(track);
@@ -1248,7 +1634,7 @@ var WaveformBar = class {
1248
1634
  }
1249
1635
  if (marker.artwork) {
1250
1636
  const artworkEl = this.barEl.querySelector(".wb-artwork");
1251
- if (artworkEl) artworkEl.innerHTML = `<img src="${marker.artwork}" alt="${marker.title || ""}" />`;
1637
+ if (artworkEl) artworkEl.innerHTML = `<img src="${escapeHtml(marker.artwork)}" alt="${escapeHtml(marker.title || "")}" />`;
1252
1638
  }
1253
1639
  if (this.metaEl && (marker.bpm || marker.key)) {
1254
1640
  const metaTrack = {
@@ -1429,24 +1815,27 @@ var WaveformBar = class {
1429
1815
  this._updateTrackDisplay(track);
1430
1816
  this._updateFavoriteUI();
1431
1817
  this._updateNavButtons();
1432
- if (track.waveform) {
1433
- this.player.options.waveform = track.waveform;
1434
- }
1435
- this.player.options.title = track.title || "";
1436
- this.player.options.subtitle = track.artist || "";
1818
+ const loadOpts = { autoplay: false };
1819
+ if (track.waveform) loadOpts.waveform = track.waveform;
1437
1820
  if (track.markers && track.markers.length) {
1438
1821
  const defaultColor = this.config.markerColor;
1439
- this.player.options.markers = track.markers.map((m) => ({
1822
+ loadOpts.markers = track.markers.map((m) => ({
1440
1823
  ...m,
1441
1824
  color: m.color || defaultColor
1442
1825
  }));
1443
1826
  this._activeMarkers = track.markers;
1444
1827
  } else {
1445
- this.player.options.markers = [];
1828
+ loadOpts.markers = [];
1446
1829
  this._activeMarkers = null;
1447
1830
  }
1448
1831
  this._currentMarkerIndex = -1;
1449
- this.player.load(track.url).then(() => {
1832
+ const seq = ++this._loadSeq;
1833
+ if (this._restoreSeekTimeout) {
1834
+ clearTimeout(this._restoreSeekTimeout);
1835
+ this._restoreSeekTimeout = null;
1836
+ }
1837
+ this.player.loadTrack(track.url, track.title, track.artist, loadOpts).then(() => {
1838
+ if (this._loadSeq !== seq) return;
1450
1839
  if (this.player) this.player.setVolume(this.isMuted ? 0 : this.volume);
1451
1840
  if (state.isPlaying && this.config.autoResume) {
1452
1841
  try {
@@ -1465,7 +1854,9 @@ var WaveformBar = class {
1465
1854
  }
1466
1855
  }
1467
1856
  if (state.position > 0) {
1468
- setTimeout(() => {
1857
+ this._restoreSeekTimeout = setTimeout(() => {
1858
+ this._restoreSeekTimeout = null;
1859
+ if (this._loadSeq !== seq) return;
1469
1860
  if (this.player) {
1470
1861
  this.player.seekTo(state.position);
1471
1862
  this._lastPosition = state.position;
@@ -1480,7 +1871,8 @@ var WaveformBar = class {
1480
1871
  _restoreVolume() {
1481
1872
  const data = restoreVolume(this.config.storageKey);
1482
1873
  if (!data) return;
1483
- this.volume = data.volume;
1874
+ const v = Number(data.volume);
1875
+ this.volume = Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 1;
1484
1876
  this.isMuted = data.muted;
1485
1877
  this._volumeBeforeMute = data.volumeBeforeMute;
1486
1878
  if (this.player) this.player.setVolume(this.isMuted ? 0 : this.volume);