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