@vanduo-oss/framework 1.3.0 → 1.3.2

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.
@@ -1,4 +1,4 @@
1
- /*! Vanduo v1.3.0 | Built: 2026-03-17T16:46:03.216Z | git:ea44b23 | development */
1
+ /*! Vanduo v1.3.2 | Built: 2026-04-06T19:04:41.601Z | git:8e08b38 | development */
2
2
 
3
3
  // js/utils/lifecycle.js
4
4
  (function() {
@@ -107,7 +107,7 @@
107
107
  // js/vanduo.js
108
108
  (function() {
109
109
  "use strict";
110
- const VANDUO_VERSION = true ? "1.3.0" : "0.0.0-dev";
110
+ const VANDUO_VERSION = true ? "1.3.2" : "0.0.0-dev";
111
111
  const Vanduo2 = {
112
112
  version: VANDUO_VERSION,
113
113
  components: {},
@@ -418,16 +418,17 @@
418
418
  }
419
419
  const codeElement = activePane.querySelector("code") || activePane;
420
420
  const code = codeElement.textContent;
421
+ let copySuccess;
421
422
  try {
422
423
  await navigator.clipboard.writeText(code);
423
- this.showCopyFeedback(copyBtn, true);
424
+ copySuccess = true;
424
425
  } catch (_err) {
425
- const success = this.fallbackCopy(code);
426
- this.showCopyFeedback(copyBtn, success);
426
+ copySuccess = this.fallbackCopy(code);
427
427
  }
428
+ this.showCopyFeedback(copyBtn, copySuccess);
428
429
  const event = new CustomEvent("codesnippet:copy", {
429
430
  bubbles: true,
430
- detail: { snippet, code, success: true }
431
+ detail: { snippet, code, success: copySuccess }
431
432
  });
432
433
  snippet.dispatchEvent(event);
433
434
  },
@@ -864,9 +865,6 @@
864
865
  const Dropdown = {
865
866
  // Store initialized dropdowns and their cleanup functions
866
867
  instances: /* @__PURE__ */ new Map(),
867
- // Typeahead state
868
- _typeaheadBuffer: "",
869
- _typeaheadTimer: null,
870
868
  /**
871
869
  * Initialize dropdown components
872
870
  */
@@ -930,7 +928,7 @@
930
928
  item.addEventListener("keydown", itemKeydownHandler);
931
929
  cleanupFunctions.push(() => item.removeEventListener("keydown", itemKeydownHandler));
932
930
  });
933
- this.instances.set(dropdown, { toggle, menu, cleanup: cleanupFunctions });
931
+ this.instances.set(dropdown, { toggle, menu, cleanup: cleanupFunctions, typeaheadBuffer: "", typeaheadTimer: null });
934
932
  },
935
933
  /**
936
934
  * Toggle dropdown
@@ -1059,16 +1057,18 @@
1059
1057
  break;
1060
1058
  default:
1061
1059
  if (isOpen && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
1062
- clearTimeout(this._typeaheadTimer);
1063
- this._typeaheadBuffer += e.key.toLowerCase();
1060
+ const instance = this.instances.get(dropdown);
1061
+ if (!instance) break;
1062
+ clearTimeout(instance.typeaheadTimer);
1063
+ instance.typeaheadBuffer += e.key.toLowerCase();
1064
1064
  const match = items.find(
1065
- (item) => item.textContent.trim().toLowerCase().startsWith(this._typeaheadBuffer)
1065
+ (item) => item.textContent.trim().toLowerCase().startsWith(instance.typeaheadBuffer)
1066
1066
  );
1067
1067
  if (match) {
1068
1068
  match.focus();
1069
1069
  }
1070
- this._typeaheadTimer = setTimeout(() => {
1071
- this._typeaheadBuffer = "";
1070
+ instance.typeaheadTimer = setTimeout(() => {
1071
+ instance.typeaheadBuffer = "";
1072
1072
  }, 500);
1073
1073
  }
1074
1074
  break;
@@ -1776,9 +1776,10 @@
1776
1776
  }));
1777
1777
  if (!this.img.complete) {
1778
1778
  this.img.style.opacity = "0";
1779
- this.img.onload = () => {
1779
+ this._imgLoadHandler = () => {
1780
1780
  this.img.style.opacity = "";
1781
1781
  };
1782
+ this.img.addEventListener("load", this._imgLoadHandler, { once: true });
1782
1783
  }
1783
1784
  },
1784
1785
  /**
@@ -1797,6 +1798,10 @@
1797
1798
  }
1798
1799
  setTimeout(() => {
1799
1800
  if (!this.isOpen) {
1801
+ if (this._imgLoadHandler) {
1802
+ this.img.removeEventListener("load", this._imgLoadHandler);
1803
+ this._imgLoadHandler = null;
1804
+ }
1800
1805
  this.img.src = "";
1801
1806
  this.img.alt = "";
1802
1807
  }
@@ -1856,6 +1861,8 @@
1856
1861
  zIndexCounter: 1050,
1857
1862
  // Store trigger cleanup functions
1858
1863
  _triggerCleanups: [],
1864
+ // Shared ESC key handler (installed once)
1865
+ _sharedEscHandler: null,
1859
1866
  /**
1860
1867
  * Initialize modals
1861
1868
  */
@@ -1920,16 +1927,17 @@
1920
1927
  };
1921
1928
  backdrop.addEventListener("click", backdropClickHandler);
1922
1929
  cleanupFunctions.push(() => backdrop.removeEventListener("click", backdropClickHandler));
1923
- const escKeyHandler = (e) => {
1924
- if (e.key === "Escape" && this.openModals.length > 0) {
1925
- const topModal = this.openModals[this.openModals.length - 1];
1926
- if (topModal === modal && topModal.dataset.keyboard !== "false") {
1927
- this.close(topModal);
1930
+ if (!this._sharedEscHandler) {
1931
+ this._sharedEscHandler = (e) => {
1932
+ if (e.key === "Escape" && this.openModals.length > 0) {
1933
+ const topModal = this.openModals[this.openModals.length - 1];
1934
+ if (topModal.dataset.keyboard !== "false") {
1935
+ this.close(topModal);
1936
+ }
1928
1937
  }
1929
- }
1930
- };
1931
- document.addEventListener("keydown", escKeyHandler);
1932
- cleanupFunctions.push(() => document.removeEventListener("keydown", escKeyHandler));
1938
+ };
1939
+ document.addEventListener("keydown", this._sharedEscHandler);
1940
+ }
1933
1941
  this.modals.set(modal, { backdrop, dialog, trapHandler: null, cleanup: cleanupFunctions });
1934
1942
  },
1935
1943
  /**
@@ -2109,6 +2117,10 @@
2109
2117
  });
2110
2118
  this._triggerCleanups.forEach((fn) => fn());
2111
2119
  this._triggerCleanups = [];
2120
+ if (this._sharedEscHandler) {
2121
+ document.removeEventListener("keydown", this._sharedEscHandler);
2122
+ this._sharedEscHandler = null;
2123
+ }
2112
2124
  }
2113
2125
  };
2114
2126
  if (typeof window.Vanduo !== "undefined") {
@@ -2274,7 +2286,7 @@
2274
2286
  if (overlay) {
2275
2287
  overlay.classList.add("is-active");
2276
2288
  }
2277
- document.body.style.overflow = "hidden";
2289
+ document.body.classList.add("body-navbar-open");
2278
2290
  toggle.setAttribute("aria-expanded", "true");
2279
2291
  menu.setAttribute("aria-hidden", "false");
2280
2292
  },
@@ -2291,7 +2303,7 @@
2291
2303
  if (overlay) {
2292
2304
  overlay.classList.remove("is-active");
2293
2305
  }
2294
- document.body.style.overflow = "";
2306
+ document.body.classList.remove("body-navbar-open");
2295
2307
  const dropdownMenus = menu.querySelectorAll(".vd-navbar-dropdown-menu.is-open");
2296
2308
  dropdownMenus.forEach((dropdownMenu) => {
2297
2309
  dropdownMenu.classList.remove("is-open");
@@ -2850,9 +2862,6 @@
2850
2862
  const Select = {
2851
2863
  // Store initialized selects and their cleanup functions
2852
2864
  instances: /* @__PURE__ */ new Map(),
2853
- // Typeahead state
2854
- _typeaheadBuffer: "",
2855
- _typeaheadTimer: null,
2856
2865
  /**
2857
2866
  * Initialize select components
2858
2867
  */
@@ -2933,7 +2942,7 @@
2933
2942
  };
2934
2943
  select.addEventListener("change", changeHandler);
2935
2944
  cleanupFunctions.push(() => select.removeEventListener("change", changeHandler));
2936
- this.instances.set(select, { wrapper, button, dropdown, cleanup: cleanupFunctions });
2945
+ this.instances.set(select, { wrapper, button, dropdown, cleanup: cleanupFunctions, typeaheadBuffer: "", typeaheadTimer: null });
2937
2946
  },
2938
2947
  /**
2939
2948
  * Build options in dropdown
@@ -3027,7 +3036,7 @@
3027
3036
  * @param {HTMLElement} dropdown - Dropdown container
3028
3037
  */
3029
3038
  updateSelectedOptions: function(select, dropdown) {
3030
- const options = dropdown.querySelectorAll(".vd-custom-select-option");
3039
+ const options = dropdown.querySelectorAll(".custom-select-option");
3031
3040
  const selectedValues = Array.from(select.selectedOptions).map((opt) => opt.value);
3032
3041
  options.forEach((optionEl) => {
3033
3042
  const value = optionEl.dataset.value;
@@ -3061,7 +3070,7 @@
3061
3070
  openDropdown: function(button, dropdown) {
3062
3071
  dropdown.classList.add("is-open");
3063
3072
  button.setAttribute("aria-expanded", "true");
3064
- const firstOption = dropdown.querySelector(".vd-custom-select-option:not(.is-disabled)");
3073
+ const firstOption = dropdown.querySelector(".custom-select-option:not(.is-disabled)");
3065
3074
  if (firstOption) {
3066
3075
  firstOption.focus();
3067
3076
  }
@@ -3084,7 +3093,7 @@
3084
3093
  */
3085
3094
  handleKeydown: function(e, select, button, dropdown) {
3086
3095
  const isOpen = dropdown.classList.contains("is-open");
3087
- const options = Array.from(dropdown.querySelectorAll(".vd-custom-select-option:not(.is-disabled)"));
3096
+ const options = Array.from(dropdown.querySelectorAll(".custom-select-option:not(.is-disabled)"));
3088
3097
  const currentIndex = options.findIndex((opt) => opt === document.activeElement);
3089
3098
  switch (e.key) {
3090
3099
  case "Enter":
@@ -3135,16 +3144,18 @@
3135
3144
  break;
3136
3145
  default:
3137
3146
  if (isOpen && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
3138
- clearTimeout(this._typeaheadTimer);
3139
- this._typeaheadBuffer += e.key.toLowerCase();
3147
+ const instance = this.instances.get(select);
3148
+ if (!instance) break;
3149
+ clearTimeout(instance.typeaheadTimer);
3150
+ instance.typeaheadBuffer += e.key.toLowerCase();
3140
3151
  const match = options.find(
3141
- (opt) => opt.textContent.trim().toLowerCase().startsWith(this._typeaheadBuffer)
3152
+ (opt) => opt.textContent.trim().toLowerCase().startsWith(instance.typeaheadBuffer)
3142
3153
  );
3143
3154
  if (match) {
3144
3155
  match.focus();
3145
3156
  }
3146
- this._typeaheadTimer = setTimeout(() => {
3147
- this._typeaheadBuffer = "";
3157
+ instance.typeaheadTimer = setTimeout(() => {
3158
+ instance.typeaheadBuffer = "";
3148
3159
  }, 500);
3149
3160
  }
3150
3161
  break;
@@ -3176,7 +3187,9 @@
3176
3187
  if (element.id) {
3177
3188
  return element.id;
3178
3189
  }
3179
- return "select-" + Math.random().toString(36).substr(2, 9);
3190
+ const id = "select-" + Math.random().toString(36).substr(2, 9);
3191
+ element.id = id;
3192
+ return id;
3180
3193
  },
3181
3194
  /**
3182
3195
  * Destroy a select instance and clean up event listeners
@@ -4230,7 +4243,6 @@
4230
4243
  this.applyNeutral(this.DEFAULTS.NEUTRAL);
4231
4244
  this.applyRadius(this.DEFAULTS.RADIUS);
4232
4245
  this.applyFont(this.DEFAULTS.FONT);
4233
- this.applyTheme(this.DEFAULTS.THEME);
4234
4246
  this.updateUI();
4235
4247
  this.dispatchEvent("reset", { state: { ...this.state } });
4236
4248
  },
@@ -7015,6 +7027,11 @@
7015
7027
  // js/components/suggest.js
7016
7028
  (function() {
7017
7029
  "use strict";
7030
+ function _escapeHtml(text) {
7031
+ const div = document.createElement("div");
7032
+ div.textContent = text;
7033
+ return div.innerHTML;
7034
+ }
7018
7035
  const Suggest = {
7019
7036
  instances: /* @__PURE__ */ new Map(),
7020
7037
  init: function() {
@@ -7074,8 +7091,9 @@
7074
7091
  li.id = listId + "-item-" + i;
7075
7092
  const text = typeof item === "object" ? item.label || item.text || String(item) : String(item);
7076
7093
  if (query) {
7094
+ const escaped = _escapeHtml(text);
7077
7095
  const re = new RegExp("(" + query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + ")", "gi");
7078
- li.innerHTML = text.replace(re, '<span class="vd-suggest-match">$1</span>');
7096
+ li.innerHTML = escaped.replace(re, '<span class="vd-suggest-match">$1</span>');
7079
7097
  } else {
7080
7098
  li.textContent = text;
7081
7099
  }
@@ -7228,14 +7246,20 @@
7228
7246
  maxVal: (value, param) => parseFloat(value) <= parseFloat(param),
7229
7247
  pattern: (value, param) => {
7230
7248
  try {
7249
+ if (param.length > 100) return false;
7231
7250
  return new RegExp(param).test(value);
7232
7251
  } catch (_e) {
7233
7252
  return false;
7234
7253
  }
7235
7254
  },
7236
7255
  match: (value, param) => {
7237
- const other = document.querySelector('[name="' + param + '"]');
7238
- return other ? value === other.value : false;
7256
+ try {
7257
+ const escaped = typeof CSS !== "undefined" && CSS.escape ? CSS.escape(param) : param;
7258
+ const other = document.querySelector('[name="' + escaped + '"]');
7259
+ return other ? value === other.value : false;
7260
+ } catch (_e) {
7261
+ return false;
7262
+ }
7239
7263
  }
7240
7264
  },
7241
7265
  messages: {
@@ -8516,6 +8540,667 @@
8516
8540
  window.VanduoSpotlight = Spotlight;
8517
8541
  })();
8518
8542
 
8543
+ // js/components/music-player.js
8544
+ (function() {
8545
+ "use strict";
8546
+ function shuffleArray(arr) {
8547
+ const shuffled = arr.slice();
8548
+ for (let i = shuffled.length - 1; i > 0; i--) {
8549
+ const j = Math.floor(Math.random() * (i + 1));
8550
+ const tmp = shuffled[i];
8551
+ shuffled[i] = shuffled[j];
8552
+ shuffled[j] = tmp;
8553
+ }
8554
+ return shuffled;
8555
+ }
8556
+ function formatTime(seconds) {
8557
+ if (!isFinite(seconds) || seconds < 0) return "0:00";
8558
+ const m = Math.floor(seconds / 60);
8559
+ const s = Math.floor(seconds % 60);
8560
+ return m + ":" + (s < 10 ? "0" : "") + s;
8561
+ }
8562
+ function updateRangeFill(input) {
8563
+ const min = parseFloat(input.min) || 0;
8564
+ const max = parseFloat(input.max) || 1;
8565
+ const val = parseFloat(input.value) || 0;
8566
+ const pct = (val - min) / (max - min) * 100;
8567
+ input.style.setProperty("--fill", pct + "%");
8568
+ input.style.backgroundImage = "linear-gradient(to right, var(--music-player-track-fill, currentColor) 0%, var(--music-player-track-fill, currentColor) " + pct + "%, var(--music-player-track-bg, #ccc) " + pct + "%, var(--music-player-track-bg, #ccc) 100%)";
8569
+ }
8570
+ function icon(name) {
8571
+ const el = document.createElement("i");
8572
+ el.className = "ph ph-" + name;
8573
+ el.setAttribute("aria-hidden", "true");
8574
+ return el;
8575
+ }
8576
+ const MusicPlayer = {
8577
+ /** @type {Map<HTMLElement, Object>} */
8578
+ instances: /* @__PURE__ */ new Map(),
8579
+ /**
8580
+ * Default options.
8581
+ */
8582
+ defaults: {
8583
+ tracks: [],
8584
+ volume: 0.5,
8585
+ shuffle: false,
8586
+ showProgress: false,
8587
+ showPlaylist: false,
8588
+ autoAdvance: true
8589
+ },
8590
+ /**
8591
+ * Auto-initialize all .vd-music-player / [data-music-player] elements.
8592
+ * Options can be provided via data-music-player-options (JSON string).
8593
+ */
8594
+ init: function() {
8595
+ document.querySelectorAll(".vd-music-player, [data-music-player]").forEach((el) => {
8596
+ if (this.instances.has(el)) return;
8597
+ let opts = {};
8598
+ const attr = el.getAttribute("data-music-player-options");
8599
+ if (attr) {
8600
+ try {
8601
+ opts = JSON.parse(attr);
8602
+ } catch (_) {
8603
+ }
8604
+ }
8605
+ this.initPlayer(el, opts);
8606
+ });
8607
+ },
8608
+ /**
8609
+ * Initialize a single player element.
8610
+ * @param {HTMLElement} container
8611
+ * @param {Object} [options]
8612
+ */
8613
+ initPlayer: function(container, options) {
8614
+ const opts = Object.assign({}, this.defaults, options || {});
8615
+ const rawTracks = Array.isArray(opts.tracks) ? opts.tracks : [];
8616
+ const tracks = rawTracks.filter((t) => t && typeof t.url === "string" && t.url.trim());
8617
+ const trackList = opts.shuffle ? shuffleArray(tracks) : tracks.slice();
8618
+ const state = {
8619
+ tracks: trackList,
8620
+ originalTracks: tracks.slice(),
8621
+ currentIndex: 0,
8622
+ isPlaying: false,
8623
+ volume: Math.max(0, Math.min(1, opts.volume)),
8624
+ shuffle: opts.shuffle,
8625
+ showProgress: opts.showProgress,
8626
+ showPlaylist: opts.showPlaylist,
8627
+ autoAdvance: opts.autoAdvance,
8628
+ audio: null
8629
+ };
8630
+ const audio = new Audio();
8631
+ audio.volume = state.volume;
8632
+ audio.preload = "metadata";
8633
+ state.audio = audio;
8634
+ this._buildDOM(container, state);
8635
+ const refs = {
8636
+ btnPlay: container.querySelector(".vd-music-player-btn-play"),
8637
+ btnPrev: container.querySelector(".vd-music-player-btn-prev"),
8638
+ btnNext: container.querySelector(".vd-music-player-btn-next"),
8639
+ btnShuffle: container.querySelector(".vd-music-player-btn-shuffle"),
8640
+ btnPlaylist: container.querySelector(".vd-music-player-btn-playlist"),
8641
+ trackName: container.querySelector(".vd-music-player-track-name"),
8642
+ volumeSlider: container.querySelector(".vd-music-player-volume-slider"),
8643
+ volumeIcon: container.querySelector(".vd-music-player-volume-icon"),
8644
+ progressBar: container.querySelector(".vd-music-player-progress-bar"),
8645
+ timeElapsed: container.querySelector(".vd-music-player-time-elapsed"),
8646
+ timeDuration: container.querySelector(".vd-music-player-time-duration"),
8647
+ playlistPanel: container.querySelector(".vd-music-player-playlist")
8648
+ };
8649
+ const renderPlayIcon = () => {
8650
+ const btn = refs.btnPlay;
8651
+ if (!btn) return;
8652
+ btn.innerHTML = "";
8653
+ btn.appendChild(icon(state.isPlaying ? "pause" : "play"));
8654
+ btn.setAttribute("aria-label", state.isPlaying ? "Pause" : "Play");
8655
+ btn.classList.toggle("is-active", state.isPlaying);
8656
+ };
8657
+ const renderTrackName = () => {
8658
+ const el = refs.trackName;
8659
+ if (!el) return;
8660
+ const track = state.tracks[state.currentIndex];
8661
+ if (track) {
8662
+ el.textContent = track.name || "Unknown Track";
8663
+ el.classList.remove("is-idle");
8664
+ } else {
8665
+ el.textContent = "No tracks loaded";
8666
+ el.classList.add("is-idle");
8667
+ }
8668
+ };
8669
+ const renderVolumeIcon = () => {
8670
+ const el = refs.volumeIcon;
8671
+ if (!el) return;
8672
+ el.innerHTML = "";
8673
+ const v = state.volume;
8674
+ const name = v === 0 ? "speaker-none" : v < 0.5 ? "speaker-low" : "speaker-high";
8675
+ el.appendChild(icon(name));
8676
+ };
8677
+ const renderShuffleBtn = () => {
8678
+ const btn = refs.btnShuffle;
8679
+ if (!btn) return;
8680
+ btn.classList.toggle("is-active", state.shuffle);
8681
+ btn.setAttribute("aria-pressed", state.shuffle ? "true" : "false");
8682
+ };
8683
+ const renderPlaylistItems = () => {
8684
+ const panel = refs.playlistPanel;
8685
+ if (!panel) return;
8686
+ panel.innerHTML = "";
8687
+ state.tracks.forEach((track, i) => {
8688
+ const item = document.createElement("button");
8689
+ item.className = "vd-music-player-playlist-item" + (i === state.currentIndex ? " is-active" : "");
8690
+ item.type = "button";
8691
+ item.setAttribute("data-index", String(i));
8692
+ item.setAttribute("aria-current", i === state.currentIndex ? "true" : "false");
8693
+ const num = document.createElement("span");
8694
+ num.className = "vd-music-player-playlist-num";
8695
+ num.textContent = String(i + 1);
8696
+ const name = document.createElement("span");
8697
+ name.className = "vd-music-player-playlist-name";
8698
+ name.textContent = track.name || "Track " + (i + 1);
8699
+ item.appendChild(num);
8700
+ item.appendChild(name);
8701
+ panel.appendChild(item);
8702
+ });
8703
+ };
8704
+ const renderProgress = () => {
8705
+ const bar = refs.progressBar;
8706
+ if (!bar || !audio.duration) return;
8707
+ const pct = audio.currentTime / audio.duration * 100;
8708
+ bar.value = String(pct);
8709
+ updateRangeFill(bar);
8710
+ if (refs.timeElapsed) refs.timeElapsed.textContent = formatTime(audio.currentTime);
8711
+ if (refs.timeDuration) refs.timeDuration.textContent = formatTime(audio.duration);
8712
+ };
8713
+ const loadTrack = (index, autoPlay) => {
8714
+ const track = state.tracks[index];
8715
+ if (!track) return;
8716
+ state.currentIndex = index;
8717
+ audio.src = track.url;
8718
+ renderTrackName();
8719
+ renderPlaylistItems();
8720
+ if (refs.progressBar) {
8721
+ refs.progressBar.value = "0";
8722
+ updateRangeFill(refs.progressBar);
8723
+ }
8724
+ if (refs.timeElapsed) refs.timeElapsed.textContent = "0:00";
8725
+ if (refs.timeDuration) refs.timeDuration.textContent = "0:00";
8726
+ container.dispatchEvent(
8727
+ new CustomEvent("musicplayer:trackchange", {
8728
+ bubbles: true,
8729
+ detail: { index, name: track.name, url: track.url }
8730
+ })
8731
+ );
8732
+ if (autoPlay) {
8733
+ audio.play().catch(() => {
8734
+ });
8735
+ }
8736
+ };
8737
+ const cleanupFunctions = [];
8738
+ const onPlay = () => {
8739
+ state.isPlaying = true;
8740
+ renderPlayIcon();
8741
+ container.dispatchEvent(new CustomEvent("musicplayer:play", { bubbles: true }));
8742
+ };
8743
+ const onPause = () => {
8744
+ state.isPlaying = false;
8745
+ renderPlayIcon();
8746
+ container.dispatchEvent(new CustomEvent("musicplayer:pause", { bubbles: true }));
8747
+ };
8748
+ const onEnded = () => {
8749
+ if (state.autoAdvance && state.tracks.length > 1) {
8750
+ const next = (state.currentIndex + 1) % state.tracks.length;
8751
+ loadTrack(next, true);
8752
+ } else {
8753
+ state.isPlaying = false;
8754
+ renderPlayIcon();
8755
+ container.dispatchEvent(new CustomEvent("musicplayer:ended", { bubbles: true }));
8756
+ }
8757
+ };
8758
+ const onTimeUpdate = () => {
8759
+ if (state.showProgress) renderProgress();
8760
+ };
8761
+ const onLoadedMetadata = () => {
8762
+ if (refs.timeDuration) refs.timeDuration.textContent = formatTime(audio.duration);
8763
+ if (refs.progressBar) {
8764
+ refs.progressBar.max = "100";
8765
+ updateRangeFill(refs.progressBar);
8766
+ }
8767
+ };
8768
+ audio.addEventListener("play", onPlay);
8769
+ audio.addEventListener("pause", onPause);
8770
+ audio.addEventListener("ended", onEnded);
8771
+ audio.addEventListener("timeupdate", onTimeUpdate);
8772
+ audio.addEventListener("loadedmetadata", onLoadedMetadata);
8773
+ cleanupFunctions.push(() => {
8774
+ audio.removeEventListener("play", onPlay);
8775
+ audio.removeEventListener("pause", onPause);
8776
+ audio.removeEventListener("ended", onEnded);
8777
+ audio.removeEventListener("timeupdate", onTimeUpdate);
8778
+ audio.removeEventListener("loadedmetadata", onLoadedMetadata);
8779
+ audio.pause();
8780
+ audio.src = "";
8781
+ });
8782
+ if (refs.btnPlay) {
8783
+ const handler = () => {
8784
+ if (!audio.src && state.tracks.length) loadTrack(state.currentIndex, false);
8785
+ if (state.isPlaying) {
8786
+ audio.pause();
8787
+ } else {
8788
+ audio.play().catch(() => {
8789
+ });
8790
+ }
8791
+ };
8792
+ refs.btnPlay.addEventListener("click", handler);
8793
+ cleanupFunctions.push(() => refs.btnPlay.removeEventListener("click", handler));
8794
+ const keyHandler = (e) => {
8795
+ if (e.key === " " || e.key === "Enter") {
8796
+ e.preventDefault();
8797
+ handler();
8798
+ }
8799
+ };
8800
+ refs.btnPlay.addEventListener("keydown", keyHandler);
8801
+ cleanupFunctions.push(() => refs.btnPlay.removeEventListener("keydown", keyHandler));
8802
+ }
8803
+ if (refs.btnPrev) {
8804
+ const handler = () => {
8805
+ if (!state.tracks.length) return;
8806
+ if (audio.currentTime > 3) {
8807
+ audio.currentTime = 0;
8808
+ } else {
8809
+ const prev = state.currentIndex === 0 ? state.tracks.length - 1 : state.currentIndex - 1;
8810
+ loadTrack(prev, state.isPlaying);
8811
+ }
8812
+ };
8813
+ refs.btnPrev.addEventListener("click", handler);
8814
+ cleanupFunctions.push(() => refs.btnPrev.removeEventListener("click", handler));
8815
+ }
8816
+ if (refs.btnNext) {
8817
+ const handler = () => {
8818
+ if (!state.tracks.length) return;
8819
+ const next = (state.currentIndex + 1) % state.tracks.length;
8820
+ loadTrack(next, state.isPlaying);
8821
+ };
8822
+ refs.btnNext.addEventListener("click", handler);
8823
+ cleanupFunctions.push(() => refs.btnNext.removeEventListener("click", handler));
8824
+ }
8825
+ if (refs.btnShuffle) {
8826
+ const handler = () => {
8827
+ state.shuffle = !state.shuffle;
8828
+ if (state.shuffle) {
8829
+ const current = state.tracks[state.currentIndex];
8830
+ state.tracks = shuffleArray(state.tracks);
8831
+ const newIdx = state.tracks.findIndex((t) => t === current);
8832
+ if (newIdx > 0) {
8833
+ state.tracks.splice(newIdx, 1);
8834
+ state.tracks.unshift(current);
8835
+ }
8836
+ state.currentIndex = 0;
8837
+ } else {
8838
+ const current = state.tracks[state.currentIndex];
8839
+ state.tracks = state.originalTracks.slice();
8840
+ state.currentIndex = state.tracks.findIndex((t) => t === current);
8841
+ if (state.currentIndex < 0) state.currentIndex = 0;
8842
+ }
8843
+ renderShuffleBtn();
8844
+ renderPlaylistItems();
8845
+ };
8846
+ refs.btnShuffle.addEventListener("click", handler);
8847
+ cleanupFunctions.push(() => refs.btnShuffle.removeEventListener("click", handler));
8848
+ }
8849
+ if (refs.btnPlaylist) {
8850
+ const handler = () => {
8851
+ const panel = refs.playlistPanel;
8852
+ if (!panel) return;
8853
+ const isOpen = panel.classList.toggle("is-open");
8854
+ refs.btnPlaylist.classList.toggle("is-active", isOpen);
8855
+ refs.btnPlaylist.setAttribute("aria-expanded", isOpen ? "true" : "false");
8856
+ };
8857
+ refs.btnPlaylist.addEventListener("click", handler);
8858
+ cleanupFunctions.push(() => refs.btnPlaylist.removeEventListener("click", handler));
8859
+ }
8860
+ if (refs.volumeSlider) {
8861
+ const handler = (e) => {
8862
+ const v = parseFloat(e.target.value);
8863
+ state.volume = v;
8864
+ audio.volume = v;
8865
+ renderVolumeIcon();
8866
+ updateRangeFill(refs.volumeSlider);
8867
+ container.dispatchEvent(
8868
+ new CustomEvent("musicplayer:volumechange", { bubbles: true, detail: { volume: v } })
8869
+ );
8870
+ };
8871
+ refs.volumeSlider.addEventListener("input", handler);
8872
+ cleanupFunctions.push(() => refs.volumeSlider.removeEventListener("input", handler));
8873
+ updateRangeFill(refs.volumeSlider);
8874
+ }
8875
+ if (refs.progressBar) {
8876
+ const handler = (e) => {
8877
+ if (!audio.duration) return;
8878
+ const pct = parseFloat(e.target.value);
8879
+ audio.currentTime = pct / 100 * audio.duration;
8880
+ updateRangeFill(refs.progressBar);
8881
+ };
8882
+ refs.progressBar.addEventListener("input", handler);
8883
+ cleanupFunctions.push(() => refs.progressBar.removeEventListener("input", handler));
8884
+ }
8885
+ if (refs.playlistPanel) {
8886
+ const panelHandler = (e) => {
8887
+ const item = e.target.closest(".vd-music-player-playlist-item");
8888
+ if (!item) return;
8889
+ const idx = parseInt(item.getAttribute("data-index"), 10);
8890
+ if (!isNaN(idx)) loadTrack(idx, true);
8891
+ };
8892
+ refs.playlistPanel.addEventListener("click", panelHandler);
8893
+ cleanupFunctions.push(
8894
+ () => refs.playlistPanel.removeEventListener("click", panelHandler)
8895
+ );
8896
+ }
8897
+ renderPlayIcon();
8898
+ renderTrackName();
8899
+ renderVolumeIcon();
8900
+ if (opts.showPlaylist) renderPlaylistItems();
8901
+ this.instances.set(container, { state, audio, refs, cleanup: cleanupFunctions });
8902
+ container.setAttribute("data-music-player-initialized", "true");
8903
+ },
8904
+ /* ─── DOM builder ─────────────────────────────────────── */
8905
+ /**
8906
+ * Build the inner DOM structure inside container.
8907
+ * Pre-existing inner content is replaced only if it has no
8908
+ * recognised child elements (allows server-rendered markup).
8909
+ * @param {HTMLElement} container
8910
+ * @param {Object} state
8911
+ */
8912
+ _buildDOM: function(container, state) {
8913
+ if (container.querySelector(".vd-music-player-controls")) return;
8914
+ container.setAttribute("role", "region");
8915
+ container.setAttribute("aria-label", "Music Player");
8916
+ if (state.showProgress) container.classList.add("has-progress");
8917
+ if (state.showPlaylist) container.classList.add("has-playlist");
8918
+ const info = document.createElement("div");
8919
+ info.className = "vd-music-player-info";
8920
+ const iconWrap = document.createElement("span");
8921
+ iconWrap.className = "vd-music-player-icon";
8922
+ iconWrap.setAttribute("aria-hidden", "true");
8923
+ iconWrap.appendChild(icon("music-note"));
8924
+ const trackName = document.createElement("span");
8925
+ trackName.className = "vd-music-player-track-name";
8926
+ trackName.setAttribute("aria-live", "polite");
8927
+ trackName.setAttribute("aria-atomic", "true");
8928
+ info.appendChild(iconWrap);
8929
+ info.appendChild(trackName);
8930
+ container.appendChild(info);
8931
+ const controls = document.createElement("div");
8932
+ controls.className = "vd-music-player-controls";
8933
+ controls.setAttribute("role", "group");
8934
+ controls.setAttribute("aria-label", "Playback controls");
8935
+ const btnPrev = document.createElement("button");
8936
+ btnPrev.type = "button";
8937
+ btnPrev.className = "vd-music-player-btn vd-music-player-btn-prev";
8938
+ btnPrev.setAttribute("aria-label", "Previous track");
8939
+ btnPrev.appendChild(icon("skip-back"));
8940
+ const btnPlay = document.createElement("button");
8941
+ btnPlay.type = "button";
8942
+ btnPlay.className = "vd-music-player-btn vd-music-player-btn-play";
8943
+ btnPlay.setAttribute("aria-label", "Play");
8944
+ btnPlay.appendChild(icon("play"));
8945
+ const btnNext = document.createElement("button");
8946
+ btnNext.type = "button";
8947
+ btnNext.className = "vd-music-player-btn vd-music-player-btn-next";
8948
+ btnNext.setAttribute("aria-label", "Next track");
8949
+ btnNext.appendChild(icon("skip-forward"));
8950
+ controls.appendChild(btnPrev);
8951
+ controls.appendChild(btnPlay);
8952
+ controls.appendChild(btnNext);
8953
+ if (state.showPlaylist || state.shuffle !== void 0) {
8954
+ const btnShuffle = document.createElement("button");
8955
+ btnShuffle.type = "button";
8956
+ btnShuffle.className = "vd-music-player-btn vd-music-player-btn-shuffle";
8957
+ btnShuffle.setAttribute("aria-label", "Shuffle");
8958
+ btnShuffle.setAttribute("aria-pressed", state.shuffle ? "true" : "false");
8959
+ btnShuffle.appendChild(icon("shuffle"));
8960
+ controls.appendChild(btnShuffle);
8961
+ }
8962
+ const spacer = document.createElement("span");
8963
+ spacer.className = "vd-music-player-spacer";
8964
+ spacer.setAttribute("aria-hidden", "true");
8965
+ controls.appendChild(spacer);
8966
+ const volumeWrap = document.createElement("div");
8967
+ volumeWrap.className = "vd-music-player-volume";
8968
+ const volumeIcon = document.createElement("span");
8969
+ volumeIcon.className = "vd-music-player-volume-icon";
8970
+ volumeIcon.setAttribute("aria-hidden", "true");
8971
+ const volumeSlider = document.createElement("input");
8972
+ volumeSlider.type = "range";
8973
+ volumeSlider.className = "vd-music-player-volume-slider";
8974
+ volumeSlider.min = "0";
8975
+ volumeSlider.max = "1";
8976
+ volumeSlider.step = "0.01";
8977
+ volumeSlider.value = String(state.volume);
8978
+ volumeSlider.setAttribute("aria-label", "Volume");
8979
+ volumeWrap.appendChild(volumeIcon);
8980
+ volumeWrap.appendChild(volumeSlider);
8981
+ controls.appendChild(volumeWrap);
8982
+ if (state.showPlaylist) {
8983
+ const btnPlaylist = document.createElement("button");
8984
+ btnPlaylist.type = "button";
8985
+ btnPlaylist.className = "vd-music-player-btn vd-music-player-btn-playlist";
8986
+ btnPlaylist.setAttribute("aria-label", "Show playlist");
8987
+ btnPlaylist.setAttribute("aria-expanded", "false");
8988
+ btnPlaylist.appendChild(icon("playlist"));
8989
+ controls.appendChild(btnPlaylist);
8990
+ }
8991
+ container.appendChild(controls);
8992
+ if (state.showProgress) {
8993
+ const progressRow = document.createElement("div");
8994
+ progressRow.className = "vd-music-player-progress";
8995
+ const timeElapsed = document.createElement("span");
8996
+ timeElapsed.className = "vd-music-player-time vd-music-player-time-elapsed";
8997
+ timeElapsed.textContent = "0:00";
8998
+ timeElapsed.setAttribute("aria-hidden", "true");
8999
+ const progressBar = document.createElement("input");
9000
+ progressBar.type = "range";
9001
+ progressBar.className = "vd-music-player-progress-bar";
9002
+ progressBar.min = "0";
9003
+ progressBar.max = "100";
9004
+ progressBar.step = "0.1";
9005
+ progressBar.value = "0";
9006
+ progressBar.setAttribute("aria-label", "Seek");
9007
+ const timeDuration = document.createElement("span");
9008
+ timeDuration.className = "vd-music-player-time vd-music-player-time-duration";
9009
+ timeDuration.textContent = "0:00";
9010
+ timeDuration.setAttribute("aria-hidden", "true");
9011
+ progressRow.appendChild(timeElapsed);
9012
+ progressRow.appendChild(progressBar);
9013
+ progressRow.appendChild(timeDuration);
9014
+ container.appendChild(progressRow);
9015
+ }
9016
+ if (state.showPlaylist) {
9017
+ const playlist = document.createElement("div");
9018
+ playlist.className = "vd-music-player-playlist";
9019
+ playlist.setAttribute("aria-label", "Playlist");
9020
+ container.appendChild(playlist);
9021
+ }
9022
+ },
9023
+ /* ─── Public API ──────────────────────────────────────── */
9024
+ /**
9025
+ * @param {HTMLElement} container
9026
+ */
9027
+ play: function(container) {
9028
+ const inst = this.instances.get(container);
9029
+ if (!inst) return;
9030
+ if (!inst.audio.src && inst.state.tracks.length) {
9031
+ inst.audio.src = inst.state.tracks[inst.state.currentIndex].url;
9032
+ }
9033
+ inst.audio.play().catch(() => {
9034
+ });
9035
+ },
9036
+ /**
9037
+ * @param {HTMLElement} container
9038
+ */
9039
+ pause: function(container) {
9040
+ const inst = this.instances.get(container);
9041
+ if (inst) inst.audio.pause();
9042
+ },
9043
+ /**
9044
+ * @param {HTMLElement} container
9045
+ */
9046
+ toggle: function(container) {
9047
+ const inst = this.instances.get(container);
9048
+ if (!inst) return;
9049
+ if (inst.state.isPlaying) {
9050
+ this.pause(container);
9051
+ } else {
9052
+ this.play(container);
9053
+ }
9054
+ },
9055
+ /**
9056
+ * @param {HTMLElement} container
9057
+ */
9058
+ next: function(container) {
9059
+ const inst = this.instances.get(container);
9060
+ if (!inst || !inst.state.tracks.length) return;
9061
+ const next = (inst.state.currentIndex + 1) % inst.state.tracks.length;
9062
+ this._loadTrack(inst, next, inst.state.isPlaying);
9063
+ },
9064
+ /**
9065
+ * @param {HTMLElement} container
9066
+ */
9067
+ previous: function(container) {
9068
+ const inst = this.instances.get(container);
9069
+ if (!inst || !inst.state.tracks.length) return;
9070
+ const len = inst.state.tracks.length;
9071
+ const prev = (inst.state.currentIndex - 1 + len) % len;
9072
+ this._loadTrack(inst, prev, inst.state.isPlaying);
9073
+ },
9074
+ /**
9075
+ * @param {HTMLElement} container
9076
+ * @param {number} value - 0 to 1
9077
+ */
9078
+ setVolume: function(container, value) {
9079
+ const inst = this.instances.get(container);
9080
+ if (!inst) return;
9081
+ const v = Math.max(0, Math.min(1, value));
9082
+ inst.state.volume = v;
9083
+ inst.audio.volume = v;
9084
+ if (inst.refs.volumeSlider) {
9085
+ inst.refs.volumeSlider.value = String(v);
9086
+ updateRangeFill(inst.refs.volumeSlider);
9087
+ }
9088
+ container.dispatchEvent(
9089
+ new CustomEvent("musicplayer:volumechange", { bubbles: true, detail: { volume: v } })
9090
+ );
9091
+ },
9092
+ /**
9093
+ * @param {HTMLElement} container
9094
+ * @param {number} index - Track index
9095
+ */
9096
+ setTrack: function(container, index) {
9097
+ const inst = this.instances.get(container);
9098
+ if (!inst) return;
9099
+ this._loadTrack(inst, index, inst.state.isPlaying);
9100
+ },
9101
+ /**
9102
+ * Shuffle or un-shuffle the track list.
9103
+ * @param {HTMLElement} container
9104
+ */
9105
+ shuffle: function(container) {
9106
+ const inst = this.instances.get(container);
9107
+ if (!inst || !inst.refs.btnShuffle) return;
9108
+ inst.refs.btnShuffle.click();
9109
+ },
9110
+ /**
9111
+ * Return a shallow copy of the current player state.
9112
+ * @param {HTMLElement} container
9113
+ * @returns {Object|null}
9114
+ */
9115
+ getState: function(container) {
9116
+ const inst = this.instances.get(container);
9117
+ if (!inst) return null;
9118
+ const s = inst.state;
9119
+ return {
9120
+ isPlaying: s.isPlaying,
9121
+ currentIndex: s.currentIndex,
9122
+ currentTrack: s.tracks[s.currentIndex] || null,
9123
+ volume: s.volume,
9124
+ shuffle: s.shuffle,
9125
+ tracks: s.tracks.slice()
9126
+ };
9127
+ },
9128
+ /**
9129
+ * Stop playback, clean up listeners, remove instance.
9130
+ * @param {HTMLElement} container
9131
+ */
9132
+ destroy: function(container) {
9133
+ const inst = this.instances.get(container);
9134
+ if (!inst) return;
9135
+ inst.cleanup.forEach((fn) => fn());
9136
+ this.instances.delete(container);
9137
+ container.removeAttribute("data-music-player-initialized");
9138
+ },
9139
+ /**
9140
+ * Destroy all instances.
9141
+ */
9142
+ destroyAll: function() {
9143
+ this.instances.forEach((_, container) => this.destroy(container));
9144
+ },
9145
+ /* ─── Internal helpers ────────────────────────────────── */
9146
+ /**
9147
+ * Load track by index on an already-initialised instance object.
9148
+ * @param {Object} inst
9149
+ * @param {number} index
9150
+ * @param {boolean} autoPlay
9151
+ */
9152
+ _loadTrack: function(inst, index, autoPlay) {
9153
+ const track = inst.state.tracks[index];
9154
+ if (!track) return;
9155
+ const container = this._containerOf(inst);
9156
+ inst.state.currentIndex = index;
9157
+ inst.audio.src = track.url;
9158
+ if (inst.refs.trackName) {
9159
+ inst.refs.trackName.textContent = track.name || "Unknown Track";
9160
+ inst.refs.trackName.classList.remove("is-idle");
9161
+ }
9162
+ if (inst.refs.playlistPanel) {
9163
+ inst.refs.playlistPanel.querySelectorAll(".vd-music-player-playlist-item").forEach((item, i) => {
9164
+ const active = i === index;
9165
+ item.classList.toggle("is-active", active);
9166
+ item.setAttribute("aria-current", active ? "true" : "false");
9167
+ });
9168
+ }
9169
+ if (inst.refs.progressBar) {
9170
+ inst.refs.progressBar.value = "0";
9171
+ updateRangeFill(inst.refs.progressBar);
9172
+ }
9173
+ if (inst.refs.timeElapsed) inst.refs.timeElapsed.textContent = "0:00";
9174
+ if (inst.refs.timeDuration) inst.refs.timeDuration.textContent = "0:00";
9175
+ if (container) {
9176
+ container.dispatchEvent(
9177
+ new CustomEvent("musicplayer:trackchange", {
9178
+ bubbles: true,
9179
+ detail: { index, name: track.name, url: track.url }
9180
+ })
9181
+ );
9182
+ }
9183
+ if (autoPlay) inst.audio.play().catch(() => {
9184
+ });
9185
+ },
9186
+ /**
9187
+ * Reverse-lookup the container element for a given instance object.
9188
+ * @param {Object} inst
9189
+ * @returns {HTMLElement|null}
9190
+ */
9191
+ _containerOf: function(inst) {
9192
+ for (const [container, i] of this.instances) {
9193
+ if (i === inst) return container;
9194
+ }
9195
+ return null;
9196
+ }
9197
+ };
9198
+ if (typeof window.Vanduo !== "undefined") {
9199
+ window.Vanduo.register("musicPlayer", MusicPlayer);
9200
+ }
9201
+ window.VanduoMusicPlayer = MusicPlayer;
9202
+ })();
9203
+
8519
9204
  // js/index.js
8520
9205
  var Vanduo = window.Vanduo;
8521
9206
  var index_default = Vanduo;