@arraypress/waveform-bar 1.0.0

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