@vanduo-oss/framework 1.3.1 → 1.3.3

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.1 | Built: 2026-03-20T21:48:40.922Z | git:7e73bb8 | development */
1
+ /*! Vanduo v1.3.3 | Built: 2026-04-10T21:45:12.664Z | git:281f4f6 | 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.1" : "0.0.0-dev";
110
+ const VANDUO_VERSION = true ? "1.3.3" : "0.0.0-dev";
111
111
  const Vanduo2 = {
112
112
  version: VANDUO_VERSION,
113
113
  components: {},
@@ -3806,6 +3806,7 @@
3806
3806
  loadPreferences: function() {
3807
3807
  this.state.theme = this.getStorageValue(this.STORAGE_KEYS.THEME, this.DEFAULTS.THEME);
3808
3808
  this.state.primary = this.getStorageValue(this.STORAGE_KEYS.PRIMARY, this.getDefaultPrimary(this.state.theme));
3809
+ this._normalizeDefaultPrimaryIfStaleWithStoredTheme();
3809
3810
  this.state.neutral = this.getStorageValue(this.STORAGE_KEYS.NEUTRAL, this.DEFAULTS.NEUTRAL);
3810
3811
  this.state.radius = this.getStorageValue(this.STORAGE_KEYS.RADIUS, this.DEFAULTS.RADIUS);
3811
3812
  this.state.font = this.getStorageValue(this.STORAGE_KEYS.FONT, this.DEFAULTS.FONT);
@@ -3891,12 +3892,10 @@
3891
3892
  mode = this.DEFAULTS.THEME;
3892
3893
  }
3893
3894
  this._isApplying = true;
3894
- const currentMode = this.state.theme;
3895
- const oldDefault = this.getDefaultPrimary(currentMode);
3896
- if (this.state.primary === oldDefault) {
3897
- const newDefault = this.getDefaultPrimary(mode);
3898
- if (newDefault !== this.state.primary) {
3899
- this.applyPrimary(newDefault);
3895
+ if (this.isUsingDefaultPrimary()) {
3896
+ const expected = this.getDefaultPrimary(mode);
3897
+ if (this.state.primary !== expected) {
3898
+ this.applyPrimary(expected);
3900
3899
  }
3901
3900
  }
3902
3901
  this.state.theme = mode;
@@ -4138,6 +4137,20 @@
4138
4137
  isUsingDefaultPrimary: function() {
4139
4138
  return this.state.primary === this.DEFAULTS.PRIMARY_LIGHT || this.state.primary === this.DEFAULTS.PRIMARY_DARK;
4140
4139
  },
4140
+ /**
4141
+ * When primary is still one of the auto-default palette keys (black/amber) but
4142
+ * localStorage was written under a different theme (or OS changed in system mode),
4143
+ * align in-memory state before applyAllPreferences runs — avoids amber+light / black+dark drift.
4144
+ */
4145
+ _normalizeDefaultPrimaryIfStaleWithStoredTheme: function() {
4146
+ if (!this.isUsingDefaultPrimary()) {
4147
+ return;
4148
+ }
4149
+ const expected = this.getDefaultPrimary(this.state.theme);
4150
+ if (this.state.primary !== expected) {
4151
+ this.state.primary = expected;
4152
+ }
4153
+ },
4141
4154
  bindEvents: function() {
4142
4155
  if (this.elements.trigger) {
4143
4156
  this.addListener(this.elements.trigger, "click", (e) => {
@@ -4376,6 +4389,9 @@
4376
4389
  this._onMediaChange = (_e) => {
4377
4390
  if (this.state.preference === "system") {
4378
4391
  this.applyTheme();
4392
+ if (window.ThemeCustomizer && typeof window.ThemeCustomizer.applyTheme === "function" && !window.ThemeCustomizer._isApplying) {
4393
+ window.ThemeCustomizer.applyTheme("system");
4394
+ }
4379
4395
  }
4380
4396
  };
4381
4397
  this._mediaQuery.addEventListener("change", this._onMediaChange);
@@ -5607,6 +5623,8 @@
5607
5623
  touchState: null,
5608
5624
  // Feedback element
5609
5625
  feedbackElement: null,
5626
+ // Shared selector used by init and touch reorder
5627
+ containerSelector: ".vd-draggable-container, .vd-draggable-container-vertical",
5610
5628
  /**
5611
5629
  * Initialize draggable components
5612
5630
  */
@@ -5618,7 +5636,7 @@
5618
5636
  }
5619
5637
  this.initDraggable(element);
5620
5638
  });
5621
- const containers = document.querySelectorAll(".vd-draggable-container, .vd-draggable-container-vertical");
5639
+ const containers = document.querySelectorAll(this.containerSelector);
5622
5640
  containers.forEach((container) => {
5623
5641
  if (!this.instances.has(container)) {
5624
5642
  this.initContainer(container);
@@ -5862,10 +5880,16 @@
5862
5880
  */
5863
5881
  handleTouchStart: function(e, element) {
5864
5882
  const touch = e.touches[0];
5883
+ const rect = element.getBoundingClientRect();
5865
5884
  this.touchState = {
5866
5885
  element,
5867
5886
  startX: touch.clientX,
5868
5887
  startY: touch.clientY,
5888
+ lastX: touch.clientX,
5889
+ lastY: touch.clientY,
5890
+ // Keep preview anchored to the original grab point.
5891
+ offsetX: touch.clientX - rect.left,
5892
+ offsetY: touch.clientY - rect.top,
5869
5893
  startTime: Date.now(),
5870
5894
  isDragging: false
5871
5895
  };
@@ -5878,6 +5902,8 @@
5878
5902
  handleTouchMove: function(e, element) {
5879
5903
  if (!this.touchState) return;
5880
5904
  const touch = e.touches[0];
5905
+ this.touchState.lastX = touch.clientX;
5906
+ this.touchState.lastY = touch.clientY;
5881
5907
  const deltaX = touch.clientX - this.touchState.startX;
5882
5908
  const deltaY = touch.clientY - this.touchState.startY;
5883
5909
  if (Math.abs(deltaX) > 10 || Math.abs(deltaY) > 10) {
@@ -5890,7 +5916,10 @@
5890
5916
  element,
5891
5917
  initialPosition: { x: this.touchState.startX, y: this.touchState.startY },
5892
5918
  initialBounds: element.getBoundingClientRect(),
5893
- data: this.getData(element)
5919
+ data: this.getData(element),
5920
+ // Preserve where inside the element the drag started for accurate ghost positioning.
5921
+ offsetX: this.touchState.offsetX,
5922
+ offsetY: this.touchState.offsetY
5894
5923
  };
5895
5924
  element.dispatchEvent(new CustomEvent("draggable:start", {
5896
5925
  bubbles: true,
@@ -5912,7 +5941,8 @@
5912
5941
  delta: { x: deltaX, y: deltaY }
5913
5942
  }
5914
5943
  }));
5915
- const container = element.closest(".vd-draggable-container");
5944
+ this.updateTouchDropZone(touch.clientX, touch.clientY);
5945
+ const container = element.closest(this.containerSelector);
5916
5946
  if (container && container.contains(element)) {
5917
5947
  this.handleReorder(container, element, touch.clientX, touch.clientY);
5918
5948
  }
@@ -5927,6 +5957,17 @@
5927
5957
  handleTouchEnd: function(e, element) {
5928
5958
  if (this.touchState && this.touchState.isDragging) {
5929
5959
  if (e.cancelable) e.preventDefault();
5960
+ const endTouch = e.changedTouches?.[0];
5961
+ const endPosition = {
5962
+ x: endTouch?.clientX ?? this.touchState.lastX ?? this.touchState.startX,
5963
+ y: endTouch?.clientY ?? this.touchState.lastY ?? this.touchState.startY
5964
+ };
5965
+ const dropZone = this.resolveDropZoneAtPoint(endPosition.x, endPosition.y) || this.touchState.overZone;
5966
+ if (dropZone) {
5967
+ this.dispatchDrop(dropZone, endPosition);
5968
+ } else if (this.touchState.overZone) {
5969
+ this.touchState.overZone.classList.remove("is-drag-over");
5970
+ }
5930
5971
  element.classList.remove("is-dragging");
5931
5972
  element.classList.add("is-dropped");
5932
5973
  element.setAttribute("aria-grabbed", "false");
@@ -5934,7 +5975,6 @@
5934
5975
  if (this.feedbackElement) {
5935
5976
  this.feedbackElement.classList.add("hidden");
5936
5977
  }
5937
- const endTouch = e.changedTouches[0];
5938
5978
  const data = this.currentDrag?.data || this.getData(element);
5939
5979
  const startX = this.touchState?.startX || 0;
5940
5980
  const startY = this.touchState?.startY || 0;
@@ -5943,10 +5983,10 @@
5943
5983
  detail: {
5944
5984
  element,
5945
5985
  data,
5946
- position: { x: endTouch.clientX, y: endTouch.clientY },
5986
+ position: endPosition,
5947
5987
  delta: {
5948
- x: endTouch.clientX - startX,
5949
- y: endTouch.clientY - startY
5988
+ x: endPosition.x - startX,
5989
+ y: endPosition.y - startY
5950
5990
  }
5951
5991
  }
5952
5992
  }));
@@ -5987,6 +6027,58 @@
5987
6027
  */
5988
6028
  handleDrop: function(e, zone) {
5989
6029
  e.preventDefault();
6030
+ this.dispatchDrop(zone, { x: e.clientX, y: e.clientY });
6031
+ },
6032
+ /**
6033
+ * Resolve a drop zone from viewport coordinates
6034
+ * @param {number} x
6035
+ * @param {number} y
6036
+ * @returns {HTMLElement|null}
6037
+ */
6038
+ resolveDropZoneAtPoint: function(x, y) {
6039
+ if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
6040
+ if (typeof document.elementsFromPoint === "function") {
6041
+ const stacked = document.elementsFromPoint(x, y);
6042
+ for (const element of stacked) {
6043
+ const zone = element.closest(".vd-drop-zone");
6044
+ if (zone) return zone;
6045
+ }
6046
+ }
6047
+ const target = document.elementFromPoint(x, y);
6048
+ const targetZone = target ? target.closest(".vd-drop-zone") : null;
6049
+ if (targetZone) return targetZone;
6050
+ const zones = document.querySelectorAll(".vd-drop-zone");
6051
+ for (const zone of zones) {
6052
+ const rect = zone.getBoundingClientRect();
6053
+ if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) {
6054
+ return zone;
6055
+ }
6056
+ }
6057
+ return null;
6058
+ },
6059
+ /**
6060
+ * Track and update active drop-zone hover state on touch devices
6061
+ * @param {number} x
6062
+ * @param {number} y
6063
+ */
6064
+ updateTouchDropZone: function(x, y) {
6065
+ if (!this.touchState) return;
6066
+ const nextZone = this.resolveDropZoneAtPoint(x, y);
6067
+ const prevZone = this.touchState.overZone || null;
6068
+ if (prevZone && prevZone !== nextZone) {
6069
+ prevZone.classList.remove("is-drag-over");
6070
+ }
6071
+ if (nextZone && nextZone !== prevZone) {
6072
+ nextZone.classList.add("is-drag-over");
6073
+ }
6074
+ this.touchState.overZone = nextZone || null;
6075
+ },
6076
+ /**
6077
+ * Dispatch a normalized drop event for mouse and touch flows
6078
+ * @param {HTMLElement} zone
6079
+ * @param {{x:number, y:number}} position
6080
+ */
6081
+ dispatchDrop: function(zone, position) {
5990
6082
  zone.classList.remove("is-drag-over");
5991
6083
  zone.dispatchEvent(new CustomEvent("draggable:drop", {
5992
6084
  bubbles: true,
@@ -5994,7 +6086,7 @@
5994
6086
  zone,
5995
6087
  element: this.currentDrag?.element,
5996
6088
  data: this.currentDrag?.data,
5997
- position: { x: e.clientX, y: e.clientY }
6089
+ position
5998
6090
  }
5999
6091
  }));
6000
6092
  },
@@ -6095,9 +6187,11 @@
6095
6187
  this.feedbackElement.innerHTML = "";
6096
6188
  const clone = this.currentDrag.element.cloneNode(true);
6097
6189
  this.feedbackElement.appendChild(clone);
6190
+ const offsetX = this.currentDrag.offsetX ?? 20;
6191
+ const offsetY = this.currentDrag.offsetY ?? 20;
6098
6192
  Object.assign(this.feedbackElement.style, {
6099
- left: x - 20 + "px",
6100
- top: y - 20 + "px",
6193
+ left: x - offsetX + "px",
6194
+ top: y - offsetY + "px",
6101
6195
  width: rect.width + "px",
6102
6196
  height: rect.height + "px"
6103
6197
  });
@@ -8540,6 +8634,667 @@
8540
8634
  window.VanduoSpotlight = Spotlight;
8541
8635
  })();
8542
8636
 
8637
+ // js/components/music-player.js
8638
+ (function() {
8639
+ "use strict";
8640
+ function shuffleArray(arr) {
8641
+ const shuffled = arr.slice();
8642
+ for (let i = shuffled.length - 1; i > 0; i--) {
8643
+ const j = Math.floor(Math.random() * (i + 1));
8644
+ const tmp = shuffled[i];
8645
+ shuffled[i] = shuffled[j];
8646
+ shuffled[j] = tmp;
8647
+ }
8648
+ return shuffled;
8649
+ }
8650
+ function formatTime(seconds) {
8651
+ if (!isFinite(seconds) || seconds < 0) return "0:00";
8652
+ const m = Math.floor(seconds / 60);
8653
+ const s = Math.floor(seconds % 60);
8654
+ return m + ":" + (s < 10 ? "0" : "") + s;
8655
+ }
8656
+ function updateRangeFill(input) {
8657
+ const min = parseFloat(input.min) || 0;
8658
+ const max = parseFloat(input.max) || 1;
8659
+ const val = parseFloat(input.value) || 0;
8660
+ const pct = (val - min) / (max - min) * 100;
8661
+ input.style.setProperty("--fill", pct + "%");
8662
+ 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%)";
8663
+ }
8664
+ function icon(name) {
8665
+ const el = document.createElement("i");
8666
+ el.className = "ph ph-" + name;
8667
+ el.setAttribute("aria-hidden", "true");
8668
+ return el;
8669
+ }
8670
+ const MusicPlayer = {
8671
+ /** @type {Map<HTMLElement, Object>} */
8672
+ instances: /* @__PURE__ */ new Map(),
8673
+ /**
8674
+ * Default options.
8675
+ */
8676
+ defaults: {
8677
+ tracks: [],
8678
+ volume: 0.5,
8679
+ shuffle: false,
8680
+ showProgress: false,
8681
+ showPlaylist: false,
8682
+ autoAdvance: true
8683
+ },
8684
+ /**
8685
+ * Auto-initialize all .vd-music-player / [data-music-player] elements.
8686
+ * Options can be provided via data-music-player-options (JSON string).
8687
+ */
8688
+ init: function() {
8689
+ document.querySelectorAll(".vd-music-player, [data-music-player]").forEach((el) => {
8690
+ if (this.instances.has(el)) return;
8691
+ let opts = {};
8692
+ const attr = el.getAttribute("data-music-player-options");
8693
+ if (attr) {
8694
+ try {
8695
+ opts = JSON.parse(attr);
8696
+ } catch (_) {
8697
+ }
8698
+ }
8699
+ this.initPlayer(el, opts);
8700
+ });
8701
+ },
8702
+ /**
8703
+ * Initialize a single player element.
8704
+ * @param {HTMLElement} container
8705
+ * @param {Object} [options]
8706
+ */
8707
+ initPlayer: function(container, options) {
8708
+ const opts = Object.assign({}, this.defaults, options || {});
8709
+ const rawTracks = Array.isArray(opts.tracks) ? opts.tracks : [];
8710
+ const tracks = rawTracks.filter((t) => t && typeof t.url === "string" && t.url.trim());
8711
+ const trackList = opts.shuffle ? shuffleArray(tracks) : tracks.slice();
8712
+ const state = {
8713
+ tracks: trackList,
8714
+ originalTracks: tracks.slice(),
8715
+ currentIndex: 0,
8716
+ isPlaying: false,
8717
+ volume: Math.max(0, Math.min(1, opts.volume)),
8718
+ shuffle: opts.shuffle,
8719
+ showProgress: opts.showProgress,
8720
+ showPlaylist: opts.showPlaylist,
8721
+ autoAdvance: opts.autoAdvance,
8722
+ audio: null
8723
+ };
8724
+ const audio = new Audio();
8725
+ audio.volume = state.volume;
8726
+ audio.preload = "metadata";
8727
+ state.audio = audio;
8728
+ this._buildDOM(container, state);
8729
+ const refs = {
8730
+ btnPlay: container.querySelector(".vd-music-player-btn-play"),
8731
+ btnPrev: container.querySelector(".vd-music-player-btn-prev"),
8732
+ btnNext: container.querySelector(".vd-music-player-btn-next"),
8733
+ btnShuffle: container.querySelector(".vd-music-player-btn-shuffle"),
8734
+ btnPlaylist: container.querySelector(".vd-music-player-btn-playlist"),
8735
+ trackName: container.querySelector(".vd-music-player-track-name"),
8736
+ volumeSlider: container.querySelector(".vd-music-player-volume-slider"),
8737
+ volumeIcon: container.querySelector(".vd-music-player-volume-icon"),
8738
+ progressBar: container.querySelector(".vd-music-player-progress-bar"),
8739
+ timeElapsed: container.querySelector(".vd-music-player-time-elapsed"),
8740
+ timeDuration: container.querySelector(".vd-music-player-time-duration"),
8741
+ playlistPanel: container.querySelector(".vd-music-player-playlist")
8742
+ };
8743
+ const renderPlayIcon = () => {
8744
+ const btn = refs.btnPlay;
8745
+ if (!btn) return;
8746
+ btn.innerHTML = "";
8747
+ btn.appendChild(icon(state.isPlaying ? "pause" : "play"));
8748
+ btn.setAttribute("aria-label", state.isPlaying ? "Pause" : "Play");
8749
+ btn.classList.toggle("is-active", state.isPlaying);
8750
+ };
8751
+ const renderTrackName = () => {
8752
+ const el = refs.trackName;
8753
+ if (!el) return;
8754
+ const track = state.tracks[state.currentIndex];
8755
+ if (track) {
8756
+ el.textContent = track.name || "Unknown Track";
8757
+ el.classList.remove("is-idle");
8758
+ } else {
8759
+ el.textContent = "No tracks loaded";
8760
+ el.classList.add("is-idle");
8761
+ }
8762
+ };
8763
+ const renderVolumeIcon = () => {
8764
+ const el = refs.volumeIcon;
8765
+ if (!el) return;
8766
+ el.innerHTML = "";
8767
+ const v = state.volume;
8768
+ const name = v === 0 ? "speaker-none" : v < 0.5 ? "speaker-low" : "speaker-high";
8769
+ el.appendChild(icon(name));
8770
+ };
8771
+ const renderShuffleBtn = () => {
8772
+ const btn = refs.btnShuffle;
8773
+ if (!btn) return;
8774
+ btn.classList.toggle("is-active", state.shuffle);
8775
+ btn.setAttribute("aria-pressed", state.shuffle ? "true" : "false");
8776
+ };
8777
+ const renderPlaylistItems = () => {
8778
+ const panel = refs.playlistPanel;
8779
+ if (!panel) return;
8780
+ panel.innerHTML = "";
8781
+ state.tracks.forEach((track, i) => {
8782
+ const item = document.createElement("button");
8783
+ item.className = "vd-music-player-playlist-item" + (i === state.currentIndex ? " is-active" : "");
8784
+ item.type = "button";
8785
+ item.setAttribute("data-index", String(i));
8786
+ item.setAttribute("aria-current", i === state.currentIndex ? "true" : "false");
8787
+ const num = document.createElement("span");
8788
+ num.className = "vd-music-player-playlist-num";
8789
+ num.textContent = String(i + 1);
8790
+ const name = document.createElement("span");
8791
+ name.className = "vd-music-player-playlist-name";
8792
+ name.textContent = track.name || "Track " + (i + 1);
8793
+ item.appendChild(num);
8794
+ item.appendChild(name);
8795
+ panel.appendChild(item);
8796
+ });
8797
+ };
8798
+ const renderProgress = () => {
8799
+ const bar = refs.progressBar;
8800
+ if (!bar || !audio.duration) return;
8801
+ const pct = audio.currentTime / audio.duration * 100;
8802
+ bar.value = String(pct);
8803
+ updateRangeFill(bar);
8804
+ if (refs.timeElapsed) refs.timeElapsed.textContent = formatTime(audio.currentTime);
8805
+ if (refs.timeDuration) refs.timeDuration.textContent = formatTime(audio.duration);
8806
+ };
8807
+ const loadTrack = (index, autoPlay) => {
8808
+ const track = state.tracks[index];
8809
+ if (!track) return;
8810
+ state.currentIndex = index;
8811
+ audio.src = track.url;
8812
+ renderTrackName();
8813
+ renderPlaylistItems();
8814
+ if (refs.progressBar) {
8815
+ refs.progressBar.value = "0";
8816
+ updateRangeFill(refs.progressBar);
8817
+ }
8818
+ if (refs.timeElapsed) refs.timeElapsed.textContent = "0:00";
8819
+ if (refs.timeDuration) refs.timeDuration.textContent = "0:00";
8820
+ container.dispatchEvent(
8821
+ new CustomEvent("musicplayer:trackchange", {
8822
+ bubbles: true,
8823
+ detail: { index, name: track.name, url: track.url }
8824
+ })
8825
+ );
8826
+ if (autoPlay) {
8827
+ audio.play().catch(() => {
8828
+ });
8829
+ }
8830
+ };
8831
+ const cleanupFunctions = [];
8832
+ const onPlay = () => {
8833
+ state.isPlaying = true;
8834
+ renderPlayIcon();
8835
+ container.dispatchEvent(new CustomEvent("musicplayer:play", { bubbles: true }));
8836
+ };
8837
+ const onPause = () => {
8838
+ state.isPlaying = false;
8839
+ renderPlayIcon();
8840
+ container.dispatchEvent(new CustomEvent("musicplayer:pause", { bubbles: true }));
8841
+ };
8842
+ const onEnded = () => {
8843
+ if (state.autoAdvance && state.tracks.length > 1) {
8844
+ const next = (state.currentIndex + 1) % state.tracks.length;
8845
+ loadTrack(next, true);
8846
+ } else {
8847
+ state.isPlaying = false;
8848
+ renderPlayIcon();
8849
+ container.dispatchEvent(new CustomEvent("musicplayer:ended", { bubbles: true }));
8850
+ }
8851
+ };
8852
+ const onTimeUpdate = () => {
8853
+ if (state.showProgress) renderProgress();
8854
+ };
8855
+ const onLoadedMetadata = () => {
8856
+ if (refs.timeDuration) refs.timeDuration.textContent = formatTime(audio.duration);
8857
+ if (refs.progressBar) {
8858
+ refs.progressBar.max = "100";
8859
+ updateRangeFill(refs.progressBar);
8860
+ }
8861
+ };
8862
+ audio.addEventListener("play", onPlay);
8863
+ audio.addEventListener("pause", onPause);
8864
+ audio.addEventListener("ended", onEnded);
8865
+ audio.addEventListener("timeupdate", onTimeUpdate);
8866
+ audio.addEventListener("loadedmetadata", onLoadedMetadata);
8867
+ cleanupFunctions.push(() => {
8868
+ audio.removeEventListener("play", onPlay);
8869
+ audio.removeEventListener("pause", onPause);
8870
+ audio.removeEventListener("ended", onEnded);
8871
+ audio.removeEventListener("timeupdate", onTimeUpdate);
8872
+ audio.removeEventListener("loadedmetadata", onLoadedMetadata);
8873
+ audio.pause();
8874
+ audio.src = "";
8875
+ });
8876
+ if (refs.btnPlay) {
8877
+ const handler = () => {
8878
+ if (!audio.src && state.tracks.length) loadTrack(state.currentIndex, false);
8879
+ if (state.isPlaying) {
8880
+ audio.pause();
8881
+ } else {
8882
+ audio.play().catch(() => {
8883
+ });
8884
+ }
8885
+ };
8886
+ refs.btnPlay.addEventListener("click", handler);
8887
+ cleanupFunctions.push(() => refs.btnPlay.removeEventListener("click", handler));
8888
+ const keyHandler = (e) => {
8889
+ if (e.key === " " || e.key === "Enter") {
8890
+ e.preventDefault();
8891
+ handler();
8892
+ }
8893
+ };
8894
+ refs.btnPlay.addEventListener("keydown", keyHandler);
8895
+ cleanupFunctions.push(() => refs.btnPlay.removeEventListener("keydown", keyHandler));
8896
+ }
8897
+ if (refs.btnPrev) {
8898
+ const handler = () => {
8899
+ if (!state.tracks.length) return;
8900
+ if (audio.currentTime > 3) {
8901
+ audio.currentTime = 0;
8902
+ } else {
8903
+ const prev = state.currentIndex === 0 ? state.tracks.length - 1 : state.currentIndex - 1;
8904
+ loadTrack(prev, state.isPlaying);
8905
+ }
8906
+ };
8907
+ refs.btnPrev.addEventListener("click", handler);
8908
+ cleanupFunctions.push(() => refs.btnPrev.removeEventListener("click", handler));
8909
+ }
8910
+ if (refs.btnNext) {
8911
+ const handler = () => {
8912
+ if (!state.tracks.length) return;
8913
+ const next = (state.currentIndex + 1) % state.tracks.length;
8914
+ loadTrack(next, state.isPlaying);
8915
+ };
8916
+ refs.btnNext.addEventListener("click", handler);
8917
+ cleanupFunctions.push(() => refs.btnNext.removeEventListener("click", handler));
8918
+ }
8919
+ if (refs.btnShuffle) {
8920
+ const handler = () => {
8921
+ state.shuffle = !state.shuffle;
8922
+ if (state.shuffle) {
8923
+ const current = state.tracks[state.currentIndex];
8924
+ state.tracks = shuffleArray(state.tracks);
8925
+ const newIdx = state.tracks.findIndex((t) => t === current);
8926
+ if (newIdx > 0) {
8927
+ state.tracks.splice(newIdx, 1);
8928
+ state.tracks.unshift(current);
8929
+ }
8930
+ state.currentIndex = 0;
8931
+ } else {
8932
+ const current = state.tracks[state.currentIndex];
8933
+ state.tracks = state.originalTracks.slice();
8934
+ state.currentIndex = state.tracks.findIndex((t) => t === current);
8935
+ if (state.currentIndex < 0) state.currentIndex = 0;
8936
+ }
8937
+ renderShuffleBtn();
8938
+ renderPlaylistItems();
8939
+ };
8940
+ refs.btnShuffle.addEventListener("click", handler);
8941
+ cleanupFunctions.push(() => refs.btnShuffle.removeEventListener("click", handler));
8942
+ }
8943
+ if (refs.btnPlaylist) {
8944
+ const handler = () => {
8945
+ const panel = refs.playlistPanel;
8946
+ if (!panel) return;
8947
+ const isOpen = panel.classList.toggle("is-open");
8948
+ refs.btnPlaylist.classList.toggle("is-active", isOpen);
8949
+ refs.btnPlaylist.setAttribute("aria-expanded", isOpen ? "true" : "false");
8950
+ };
8951
+ refs.btnPlaylist.addEventListener("click", handler);
8952
+ cleanupFunctions.push(() => refs.btnPlaylist.removeEventListener("click", handler));
8953
+ }
8954
+ if (refs.volumeSlider) {
8955
+ const handler = (e) => {
8956
+ const v = parseFloat(e.target.value);
8957
+ state.volume = v;
8958
+ audio.volume = v;
8959
+ renderVolumeIcon();
8960
+ updateRangeFill(refs.volumeSlider);
8961
+ container.dispatchEvent(
8962
+ new CustomEvent("musicplayer:volumechange", { bubbles: true, detail: { volume: v } })
8963
+ );
8964
+ };
8965
+ refs.volumeSlider.addEventListener("input", handler);
8966
+ cleanupFunctions.push(() => refs.volumeSlider.removeEventListener("input", handler));
8967
+ updateRangeFill(refs.volumeSlider);
8968
+ }
8969
+ if (refs.progressBar) {
8970
+ const handler = (e) => {
8971
+ if (!audio.duration) return;
8972
+ const pct = parseFloat(e.target.value);
8973
+ audio.currentTime = pct / 100 * audio.duration;
8974
+ updateRangeFill(refs.progressBar);
8975
+ };
8976
+ refs.progressBar.addEventListener("input", handler);
8977
+ cleanupFunctions.push(() => refs.progressBar.removeEventListener("input", handler));
8978
+ }
8979
+ if (refs.playlistPanel) {
8980
+ const panelHandler = (e) => {
8981
+ const item = e.target.closest(".vd-music-player-playlist-item");
8982
+ if (!item) return;
8983
+ const idx = parseInt(item.getAttribute("data-index"), 10);
8984
+ if (!isNaN(idx)) loadTrack(idx, true);
8985
+ };
8986
+ refs.playlistPanel.addEventListener("click", panelHandler);
8987
+ cleanupFunctions.push(
8988
+ () => refs.playlistPanel.removeEventListener("click", panelHandler)
8989
+ );
8990
+ }
8991
+ renderPlayIcon();
8992
+ renderTrackName();
8993
+ renderVolumeIcon();
8994
+ if (opts.showPlaylist) renderPlaylistItems();
8995
+ this.instances.set(container, { state, audio, refs, cleanup: cleanupFunctions });
8996
+ container.setAttribute("data-music-player-initialized", "true");
8997
+ },
8998
+ /* ─── DOM builder ─────────────────────────────────────── */
8999
+ /**
9000
+ * Build the inner DOM structure inside container.
9001
+ * Pre-existing inner content is replaced only if it has no
9002
+ * recognised child elements (allows server-rendered markup).
9003
+ * @param {HTMLElement} container
9004
+ * @param {Object} state
9005
+ */
9006
+ _buildDOM: function(container, state) {
9007
+ if (container.querySelector(".vd-music-player-controls")) return;
9008
+ container.setAttribute("role", "region");
9009
+ container.setAttribute("aria-label", "Music Player");
9010
+ if (state.showProgress) container.classList.add("has-progress");
9011
+ if (state.showPlaylist) container.classList.add("has-playlist");
9012
+ const info = document.createElement("div");
9013
+ info.className = "vd-music-player-info";
9014
+ const iconWrap = document.createElement("span");
9015
+ iconWrap.className = "vd-music-player-icon";
9016
+ iconWrap.setAttribute("aria-hidden", "true");
9017
+ iconWrap.appendChild(icon("music-note"));
9018
+ const trackName = document.createElement("span");
9019
+ trackName.className = "vd-music-player-track-name";
9020
+ trackName.setAttribute("aria-live", "polite");
9021
+ trackName.setAttribute("aria-atomic", "true");
9022
+ info.appendChild(iconWrap);
9023
+ info.appendChild(trackName);
9024
+ container.appendChild(info);
9025
+ const controls = document.createElement("div");
9026
+ controls.className = "vd-music-player-controls";
9027
+ controls.setAttribute("role", "group");
9028
+ controls.setAttribute("aria-label", "Playback controls");
9029
+ const btnPrev = document.createElement("button");
9030
+ btnPrev.type = "button";
9031
+ btnPrev.className = "vd-music-player-btn vd-music-player-btn-prev";
9032
+ btnPrev.setAttribute("aria-label", "Previous track");
9033
+ btnPrev.appendChild(icon("skip-back"));
9034
+ const btnPlay = document.createElement("button");
9035
+ btnPlay.type = "button";
9036
+ btnPlay.className = "vd-music-player-btn vd-music-player-btn-play";
9037
+ btnPlay.setAttribute("aria-label", "Play");
9038
+ btnPlay.appendChild(icon("play"));
9039
+ const btnNext = document.createElement("button");
9040
+ btnNext.type = "button";
9041
+ btnNext.className = "vd-music-player-btn vd-music-player-btn-next";
9042
+ btnNext.setAttribute("aria-label", "Next track");
9043
+ btnNext.appendChild(icon("skip-forward"));
9044
+ controls.appendChild(btnPrev);
9045
+ controls.appendChild(btnPlay);
9046
+ controls.appendChild(btnNext);
9047
+ if (state.showPlaylist || state.shuffle !== void 0) {
9048
+ const btnShuffle = document.createElement("button");
9049
+ btnShuffle.type = "button";
9050
+ btnShuffle.className = "vd-music-player-btn vd-music-player-btn-shuffle";
9051
+ btnShuffle.setAttribute("aria-label", "Shuffle");
9052
+ btnShuffle.setAttribute("aria-pressed", state.shuffle ? "true" : "false");
9053
+ btnShuffle.appendChild(icon("shuffle"));
9054
+ controls.appendChild(btnShuffle);
9055
+ }
9056
+ const spacer = document.createElement("span");
9057
+ spacer.className = "vd-music-player-spacer";
9058
+ spacer.setAttribute("aria-hidden", "true");
9059
+ controls.appendChild(spacer);
9060
+ const volumeWrap = document.createElement("div");
9061
+ volumeWrap.className = "vd-music-player-volume";
9062
+ const volumeIcon = document.createElement("span");
9063
+ volumeIcon.className = "vd-music-player-volume-icon";
9064
+ volumeIcon.setAttribute("aria-hidden", "true");
9065
+ const volumeSlider = document.createElement("input");
9066
+ volumeSlider.type = "range";
9067
+ volumeSlider.className = "vd-music-player-volume-slider";
9068
+ volumeSlider.min = "0";
9069
+ volumeSlider.max = "1";
9070
+ volumeSlider.step = "0.01";
9071
+ volumeSlider.value = String(state.volume);
9072
+ volumeSlider.setAttribute("aria-label", "Volume");
9073
+ volumeWrap.appendChild(volumeIcon);
9074
+ volumeWrap.appendChild(volumeSlider);
9075
+ controls.appendChild(volumeWrap);
9076
+ if (state.showPlaylist) {
9077
+ const btnPlaylist = document.createElement("button");
9078
+ btnPlaylist.type = "button";
9079
+ btnPlaylist.className = "vd-music-player-btn vd-music-player-btn-playlist";
9080
+ btnPlaylist.setAttribute("aria-label", "Show playlist");
9081
+ btnPlaylist.setAttribute("aria-expanded", "false");
9082
+ btnPlaylist.appendChild(icon("playlist"));
9083
+ controls.appendChild(btnPlaylist);
9084
+ }
9085
+ container.appendChild(controls);
9086
+ if (state.showProgress) {
9087
+ const progressRow = document.createElement("div");
9088
+ progressRow.className = "vd-music-player-progress";
9089
+ const timeElapsed = document.createElement("span");
9090
+ timeElapsed.className = "vd-music-player-time vd-music-player-time-elapsed";
9091
+ timeElapsed.textContent = "0:00";
9092
+ timeElapsed.setAttribute("aria-hidden", "true");
9093
+ const progressBar = document.createElement("input");
9094
+ progressBar.type = "range";
9095
+ progressBar.className = "vd-music-player-progress-bar";
9096
+ progressBar.min = "0";
9097
+ progressBar.max = "100";
9098
+ progressBar.step = "0.1";
9099
+ progressBar.value = "0";
9100
+ progressBar.setAttribute("aria-label", "Seek");
9101
+ const timeDuration = document.createElement("span");
9102
+ timeDuration.className = "vd-music-player-time vd-music-player-time-duration";
9103
+ timeDuration.textContent = "0:00";
9104
+ timeDuration.setAttribute("aria-hidden", "true");
9105
+ progressRow.appendChild(timeElapsed);
9106
+ progressRow.appendChild(progressBar);
9107
+ progressRow.appendChild(timeDuration);
9108
+ container.appendChild(progressRow);
9109
+ }
9110
+ if (state.showPlaylist) {
9111
+ const playlist = document.createElement("div");
9112
+ playlist.className = "vd-music-player-playlist";
9113
+ playlist.setAttribute("aria-label", "Playlist");
9114
+ container.appendChild(playlist);
9115
+ }
9116
+ },
9117
+ /* ─── Public API ──────────────────────────────────────── */
9118
+ /**
9119
+ * @param {HTMLElement} container
9120
+ */
9121
+ play: function(container) {
9122
+ const inst = this.instances.get(container);
9123
+ if (!inst) return;
9124
+ if (!inst.audio.src && inst.state.tracks.length) {
9125
+ inst.audio.src = inst.state.tracks[inst.state.currentIndex].url;
9126
+ }
9127
+ inst.audio.play().catch(() => {
9128
+ });
9129
+ },
9130
+ /**
9131
+ * @param {HTMLElement} container
9132
+ */
9133
+ pause: function(container) {
9134
+ const inst = this.instances.get(container);
9135
+ if (inst) inst.audio.pause();
9136
+ },
9137
+ /**
9138
+ * @param {HTMLElement} container
9139
+ */
9140
+ toggle: function(container) {
9141
+ const inst = this.instances.get(container);
9142
+ if (!inst) return;
9143
+ if (inst.state.isPlaying) {
9144
+ this.pause(container);
9145
+ } else {
9146
+ this.play(container);
9147
+ }
9148
+ },
9149
+ /**
9150
+ * @param {HTMLElement} container
9151
+ */
9152
+ next: function(container) {
9153
+ const inst = this.instances.get(container);
9154
+ if (!inst || !inst.state.tracks.length) return;
9155
+ const next = (inst.state.currentIndex + 1) % inst.state.tracks.length;
9156
+ this._loadTrack(inst, next, inst.state.isPlaying);
9157
+ },
9158
+ /**
9159
+ * @param {HTMLElement} container
9160
+ */
9161
+ previous: function(container) {
9162
+ const inst = this.instances.get(container);
9163
+ if (!inst || !inst.state.tracks.length) return;
9164
+ const len = inst.state.tracks.length;
9165
+ const prev = (inst.state.currentIndex - 1 + len) % len;
9166
+ this._loadTrack(inst, prev, inst.state.isPlaying);
9167
+ },
9168
+ /**
9169
+ * @param {HTMLElement} container
9170
+ * @param {number} value - 0 to 1
9171
+ */
9172
+ setVolume: function(container, value) {
9173
+ const inst = this.instances.get(container);
9174
+ if (!inst) return;
9175
+ const v = Math.max(0, Math.min(1, value));
9176
+ inst.state.volume = v;
9177
+ inst.audio.volume = v;
9178
+ if (inst.refs.volumeSlider) {
9179
+ inst.refs.volumeSlider.value = String(v);
9180
+ updateRangeFill(inst.refs.volumeSlider);
9181
+ }
9182
+ container.dispatchEvent(
9183
+ new CustomEvent("musicplayer:volumechange", { bubbles: true, detail: { volume: v } })
9184
+ );
9185
+ },
9186
+ /**
9187
+ * @param {HTMLElement} container
9188
+ * @param {number} index - Track index
9189
+ */
9190
+ setTrack: function(container, index) {
9191
+ const inst = this.instances.get(container);
9192
+ if (!inst) return;
9193
+ this._loadTrack(inst, index, inst.state.isPlaying);
9194
+ },
9195
+ /**
9196
+ * Shuffle or un-shuffle the track list.
9197
+ * @param {HTMLElement} container
9198
+ */
9199
+ shuffle: function(container) {
9200
+ const inst = this.instances.get(container);
9201
+ if (!inst || !inst.refs.btnShuffle) return;
9202
+ inst.refs.btnShuffle.click();
9203
+ },
9204
+ /**
9205
+ * Return a shallow copy of the current player state.
9206
+ * @param {HTMLElement} container
9207
+ * @returns {Object|null}
9208
+ */
9209
+ getState: function(container) {
9210
+ const inst = this.instances.get(container);
9211
+ if (!inst) return null;
9212
+ const s = inst.state;
9213
+ return {
9214
+ isPlaying: s.isPlaying,
9215
+ currentIndex: s.currentIndex,
9216
+ currentTrack: s.tracks[s.currentIndex] || null,
9217
+ volume: s.volume,
9218
+ shuffle: s.shuffle,
9219
+ tracks: s.tracks.slice()
9220
+ };
9221
+ },
9222
+ /**
9223
+ * Stop playback, clean up listeners, remove instance.
9224
+ * @param {HTMLElement} container
9225
+ */
9226
+ destroy: function(container) {
9227
+ const inst = this.instances.get(container);
9228
+ if (!inst) return;
9229
+ inst.cleanup.forEach((fn) => fn());
9230
+ this.instances.delete(container);
9231
+ container.removeAttribute("data-music-player-initialized");
9232
+ },
9233
+ /**
9234
+ * Destroy all instances.
9235
+ */
9236
+ destroyAll: function() {
9237
+ this.instances.forEach((_, container) => this.destroy(container));
9238
+ },
9239
+ /* ─── Internal helpers ────────────────────────────────── */
9240
+ /**
9241
+ * Load track by index on an already-initialised instance object.
9242
+ * @param {Object} inst
9243
+ * @param {number} index
9244
+ * @param {boolean} autoPlay
9245
+ */
9246
+ _loadTrack: function(inst, index, autoPlay) {
9247
+ const track = inst.state.tracks[index];
9248
+ if (!track) return;
9249
+ const container = this._containerOf(inst);
9250
+ inst.state.currentIndex = index;
9251
+ inst.audio.src = track.url;
9252
+ if (inst.refs.trackName) {
9253
+ inst.refs.trackName.textContent = track.name || "Unknown Track";
9254
+ inst.refs.trackName.classList.remove("is-idle");
9255
+ }
9256
+ if (inst.refs.playlistPanel) {
9257
+ inst.refs.playlistPanel.querySelectorAll(".vd-music-player-playlist-item").forEach((item, i) => {
9258
+ const active = i === index;
9259
+ item.classList.toggle("is-active", active);
9260
+ item.setAttribute("aria-current", active ? "true" : "false");
9261
+ });
9262
+ }
9263
+ if (inst.refs.progressBar) {
9264
+ inst.refs.progressBar.value = "0";
9265
+ updateRangeFill(inst.refs.progressBar);
9266
+ }
9267
+ if (inst.refs.timeElapsed) inst.refs.timeElapsed.textContent = "0:00";
9268
+ if (inst.refs.timeDuration) inst.refs.timeDuration.textContent = "0:00";
9269
+ if (container) {
9270
+ container.dispatchEvent(
9271
+ new CustomEvent("musicplayer:trackchange", {
9272
+ bubbles: true,
9273
+ detail: { index, name: track.name, url: track.url }
9274
+ })
9275
+ );
9276
+ }
9277
+ if (autoPlay) inst.audio.play().catch(() => {
9278
+ });
9279
+ },
9280
+ /**
9281
+ * Reverse-lookup the container element for a given instance object.
9282
+ * @param {Object} inst
9283
+ * @returns {HTMLElement|null}
9284
+ */
9285
+ _containerOf: function(inst) {
9286
+ for (const [container, i] of this.instances) {
9287
+ if (i === inst) return container;
9288
+ }
9289
+ return null;
9290
+ }
9291
+ };
9292
+ if (typeof window.Vanduo !== "undefined") {
9293
+ window.Vanduo.register("musicPlayer", MusicPlayer);
9294
+ }
9295
+ window.VanduoMusicPlayer = MusicPlayer;
9296
+ })();
9297
+
8543
9298
  // js/index.js
8544
9299
  var Vanduo = window.Vanduo;
8545
9300
  var index_default = Vanduo;