@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
  var __defProp = Object.defineProperty;
3
3
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
4
  var __getOwnPropNames = Object.getOwnPropertyNames;
@@ -132,7 +132,7 @@ module.exports = __toCommonJS(index_exports);
132
132
  // js/vanduo.js
133
133
  (function() {
134
134
  "use strict";
135
- const VANDUO_VERSION = true ? "1.3.1" : "0.0.0-dev";
135
+ const VANDUO_VERSION = true ? "1.3.3" : "0.0.0-dev";
136
136
  const Vanduo2 = {
137
137
  version: VANDUO_VERSION,
138
138
  components: {},
@@ -3831,6 +3831,7 @@ module.exports = __toCommonJS(index_exports);
3831
3831
  loadPreferences: function() {
3832
3832
  this.state.theme = this.getStorageValue(this.STORAGE_KEYS.THEME, this.DEFAULTS.THEME);
3833
3833
  this.state.primary = this.getStorageValue(this.STORAGE_KEYS.PRIMARY, this.getDefaultPrimary(this.state.theme));
3834
+ this._normalizeDefaultPrimaryIfStaleWithStoredTheme();
3834
3835
  this.state.neutral = this.getStorageValue(this.STORAGE_KEYS.NEUTRAL, this.DEFAULTS.NEUTRAL);
3835
3836
  this.state.radius = this.getStorageValue(this.STORAGE_KEYS.RADIUS, this.DEFAULTS.RADIUS);
3836
3837
  this.state.font = this.getStorageValue(this.STORAGE_KEYS.FONT, this.DEFAULTS.FONT);
@@ -3916,12 +3917,10 @@ module.exports = __toCommonJS(index_exports);
3916
3917
  mode = this.DEFAULTS.THEME;
3917
3918
  }
3918
3919
  this._isApplying = true;
3919
- const currentMode = this.state.theme;
3920
- const oldDefault = this.getDefaultPrimary(currentMode);
3921
- if (this.state.primary === oldDefault) {
3922
- const newDefault = this.getDefaultPrimary(mode);
3923
- if (newDefault !== this.state.primary) {
3924
- this.applyPrimary(newDefault);
3920
+ if (this.isUsingDefaultPrimary()) {
3921
+ const expected = this.getDefaultPrimary(mode);
3922
+ if (this.state.primary !== expected) {
3923
+ this.applyPrimary(expected);
3925
3924
  }
3926
3925
  }
3927
3926
  this.state.theme = mode;
@@ -4163,6 +4162,20 @@ module.exports = __toCommonJS(index_exports);
4163
4162
  isUsingDefaultPrimary: function() {
4164
4163
  return this.state.primary === this.DEFAULTS.PRIMARY_LIGHT || this.state.primary === this.DEFAULTS.PRIMARY_DARK;
4165
4164
  },
4165
+ /**
4166
+ * When primary is still one of the auto-default palette keys (black/amber) but
4167
+ * localStorage was written under a different theme (or OS changed in system mode),
4168
+ * align in-memory state before applyAllPreferences runs — avoids amber+light / black+dark drift.
4169
+ */
4170
+ _normalizeDefaultPrimaryIfStaleWithStoredTheme: function() {
4171
+ if (!this.isUsingDefaultPrimary()) {
4172
+ return;
4173
+ }
4174
+ const expected = this.getDefaultPrimary(this.state.theme);
4175
+ if (this.state.primary !== expected) {
4176
+ this.state.primary = expected;
4177
+ }
4178
+ },
4166
4179
  bindEvents: function() {
4167
4180
  if (this.elements.trigger) {
4168
4181
  this.addListener(this.elements.trigger, "click", (e) => {
@@ -4401,6 +4414,9 @@ module.exports = __toCommonJS(index_exports);
4401
4414
  this._onMediaChange = (_e) => {
4402
4415
  if (this.state.preference === "system") {
4403
4416
  this.applyTheme();
4417
+ if (window.ThemeCustomizer && typeof window.ThemeCustomizer.applyTheme === "function" && !window.ThemeCustomizer._isApplying) {
4418
+ window.ThemeCustomizer.applyTheme("system");
4419
+ }
4404
4420
  }
4405
4421
  };
4406
4422
  this._mediaQuery.addEventListener("change", this._onMediaChange);
@@ -5632,6 +5648,8 @@ module.exports = __toCommonJS(index_exports);
5632
5648
  touchState: null,
5633
5649
  // Feedback element
5634
5650
  feedbackElement: null,
5651
+ // Shared selector used by init and touch reorder
5652
+ containerSelector: ".vd-draggable-container, .vd-draggable-container-vertical",
5635
5653
  /**
5636
5654
  * Initialize draggable components
5637
5655
  */
@@ -5643,7 +5661,7 @@ module.exports = __toCommonJS(index_exports);
5643
5661
  }
5644
5662
  this.initDraggable(element);
5645
5663
  });
5646
- const containers = document.querySelectorAll(".vd-draggable-container, .vd-draggable-container-vertical");
5664
+ const containers = document.querySelectorAll(this.containerSelector);
5647
5665
  containers.forEach((container) => {
5648
5666
  if (!this.instances.has(container)) {
5649
5667
  this.initContainer(container);
@@ -5887,10 +5905,16 @@ module.exports = __toCommonJS(index_exports);
5887
5905
  */
5888
5906
  handleTouchStart: function(e, element) {
5889
5907
  const touch = e.touches[0];
5908
+ const rect = element.getBoundingClientRect();
5890
5909
  this.touchState = {
5891
5910
  element,
5892
5911
  startX: touch.clientX,
5893
5912
  startY: touch.clientY,
5913
+ lastX: touch.clientX,
5914
+ lastY: touch.clientY,
5915
+ // Keep preview anchored to the original grab point.
5916
+ offsetX: touch.clientX - rect.left,
5917
+ offsetY: touch.clientY - rect.top,
5894
5918
  startTime: Date.now(),
5895
5919
  isDragging: false
5896
5920
  };
@@ -5903,6 +5927,8 @@ module.exports = __toCommonJS(index_exports);
5903
5927
  handleTouchMove: function(e, element) {
5904
5928
  if (!this.touchState) return;
5905
5929
  const touch = e.touches[0];
5930
+ this.touchState.lastX = touch.clientX;
5931
+ this.touchState.lastY = touch.clientY;
5906
5932
  const deltaX = touch.clientX - this.touchState.startX;
5907
5933
  const deltaY = touch.clientY - this.touchState.startY;
5908
5934
  if (Math.abs(deltaX) > 10 || Math.abs(deltaY) > 10) {
@@ -5915,7 +5941,10 @@ module.exports = __toCommonJS(index_exports);
5915
5941
  element,
5916
5942
  initialPosition: { x: this.touchState.startX, y: this.touchState.startY },
5917
5943
  initialBounds: element.getBoundingClientRect(),
5918
- data: this.getData(element)
5944
+ data: this.getData(element),
5945
+ // Preserve where inside the element the drag started for accurate ghost positioning.
5946
+ offsetX: this.touchState.offsetX,
5947
+ offsetY: this.touchState.offsetY
5919
5948
  };
5920
5949
  element.dispatchEvent(new CustomEvent("draggable:start", {
5921
5950
  bubbles: true,
@@ -5937,7 +5966,8 @@ module.exports = __toCommonJS(index_exports);
5937
5966
  delta: { x: deltaX, y: deltaY }
5938
5967
  }
5939
5968
  }));
5940
- const container = element.closest(".vd-draggable-container");
5969
+ this.updateTouchDropZone(touch.clientX, touch.clientY);
5970
+ const container = element.closest(this.containerSelector);
5941
5971
  if (container && container.contains(element)) {
5942
5972
  this.handleReorder(container, element, touch.clientX, touch.clientY);
5943
5973
  }
@@ -5952,6 +5982,17 @@ module.exports = __toCommonJS(index_exports);
5952
5982
  handleTouchEnd: function(e, element) {
5953
5983
  if (this.touchState && this.touchState.isDragging) {
5954
5984
  if (e.cancelable) e.preventDefault();
5985
+ const endTouch = e.changedTouches?.[0];
5986
+ const endPosition = {
5987
+ x: endTouch?.clientX ?? this.touchState.lastX ?? this.touchState.startX,
5988
+ y: endTouch?.clientY ?? this.touchState.lastY ?? this.touchState.startY
5989
+ };
5990
+ const dropZone = this.resolveDropZoneAtPoint(endPosition.x, endPosition.y) || this.touchState.overZone;
5991
+ if (dropZone) {
5992
+ this.dispatchDrop(dropZone, endPosition);
5993
+ } else if (this.touchState.overZone) {
5994
+ this.touchState.overZone.classList.remove("is-drag-over");
5995
+ }
5955
5996
  element.classList.remove("is-dragging");
5956
5997
  element.classList.add("is-dropped");
5957
5998
  element.setAttribute("aria-grabbed", "false");
@@ -5959,7 +6000,6 @@ module.exports = __toCommonJS(index_exports);
5959
6000
  if (this.feedbackElement) {
5960
6001
  this.feedbackElement.classList.add("hidden");
5961
6002
  }
5962
- const endTouch = e.changedTouches[0];
5963
6003
  const data = this.currentDrag?.data || this.getData(element);
5964
6004
  const startX = this.touchState?.startX || 0;
5965
6005
  const startY = this.touchState?.startY || 0;
@@ -5968,10 +6008,10 @@ module.exports = __toCommonJS(index_exports);
5968
6008
  detail: {
5969
6009
  element,
5970
6010
  data,
5971
- position: { x: endTouch.clientX, y: endTouch.clientY },
6011
+ position: endPosition,
5972
6012
  delta: {
5973
- x: endTouch.clientX - startX,
5974
- y: endTouch.clientY - startY
6013
+ x: endPosition.x - startX,
6014
+ y: endPosition.y - startY
5975
6015
  }
5976
6016
  }
5977
6017
  }));
@@ -6012,6 +6052,58 @@ module.exports = __toCommonJS(index_exports);
6012
6052
  */
6013
6053
  handleDrop: function(e, zone) {
6014
6054
  e.preventDefault();
6055
+ this.dispatchDrop(zone, { x: e.clientX, y: e.clientY });
6056
+ },
6057
+ /**
6058
+ * Resolve a drop zone from viewport coordinates
6059
+ * @param {number} x
6060
+ * @param {number} y
6061
+ * @returns {HTMLElement|null}
6062
+ */
6063
+ resolveDropZoneAtPoint: function(x, y) {
6064
+ if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
6065
+ if (typeof document.elementsFromPoint === "function") {
6066
+ const stacked = document.elementsFromPoint(x, y);
6067
+ for (const element of stacked) {
6068
+ const zone = element.closest(".vd-drop-zone");
6069
+ if (zone) return zone;
6070
+ }
6071
+ }
6072
+ const target = document.elementFromPoint(x, y);
6073
+ const targetZone = target ? target.closest(".vd-drop-zone") : null;
6074
+ if (targetZone) return targetZone;
6075
+ const zones = document.querySelectorAll(".vd-drop-zone");
6076
+ for (const zone of zones) {
6077
+ const rect = zone.getBoundingClientRect();
6078
+ if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) {
6079
+ return zone;
6080
+ }
6081
+ }
6082
+ return null;
6083
+ },
6084
+ /**
6085
+ * Track and update active drop-zone hover state on touch devices
6086
+ * @param {number} x
6087
+ * @param {number} y
6088
+ */
6089
+ updateTouchDropZone: function(x, y) {
6090
+ if (!this.touchState) return;
6091
+ const nextZone = this.resolveDropZoneAtPoint(x, y);
6092
+ const prevZone = this.touchState.overZone || null;
6093
+ if (prevZone && prevZone !== nextZone) {
6094
+ prevZone.classList.remove("is-drag-over");
6095
+ }
6096
+ if (nextZone && nextZone !== prevZone) {
6097
+ nextZone.classList.add("is-drag-over");
6098
+ }
6099
+ this.touchState.overZone = nextZone || null;
6100
+ },
6101
+ /**
6102
+ * Dispatch a normalized drop event for mouse and touch flows
6103
+ * @param {HTMLElement} zone
6104
+ * @param {{x:number, y:number}} position
6105
+ */
6106
+ dispatchDrop: function(zone, position) {
6015
6107
  zone.classList.remove("is-drag-over");
6016
6108
  zone.dispatchEvent(new CustomEvent("draggable:drop", {
6017
6109
  bubbles: true,
@@ -6019,7 +6111,7 @@ module.exports = __toCommonJS(index_exports);
6019
6111
  zone,
6020
6112
  element: this.currentDrag?.element,
6021
6113
  data: this.currentDrag?.data,
6022
- position: { x: e.clientX, y: e.clientY }
6114
+ position
6023
6115
  }
6024
6116
  }));
6025
6117
  },
@@ -6120,9 +6212,11 @@ module.exports = __toCommonJS(index_exports);
6120
6212
  this.feedbackElement.innerHTML = "";
6121
6213
  const clone = this.currentDrag.element.cloneNode(true);
6122
6214
  this.feedbackElement.appendChild(clone);
6215
+ const offsetX = this.currentDrag.offsetX ?? 20;
6216
+ const offsetY = this.currentDrag.offsetY ?? 20;
6123
6217
  Object.assign(this.feedbackElement.style, {
6124
- left: x - 20 + "px",
6125
- top: y - 20 + "px",
6218
+ left: x - offsetX + "px",
6219
+ top: y - offsetY + "px",
6126
6220
  width: rect.width + "px",
6127
6221
  height: rect.height + "px"
6128
6222
  });
@@ -8565,6 +8659,667 @@ module.exports = __toCommonJS(index_exports);
8565
8659
  window.VanduoSpotlight = Spotlight;
8566
8660
  })();
8567
8661
 
8662
+ // js/components/music-player.js
8663
+ (function() {
8664
+ "use strict";
8665
+ function shuffleArray(arr) {
8666
+ const shuffled = arr.slice();
8667
+ for (let i = shuffled.length - 1; i > 0; i--) {
8668
+ const j = Math.floor(Math.random() * (i + 1));
8669
+ const tmp = shuffled[i];
8670
+ shuffled[i] = shuffled[j];
8671
+ shuffled[j] = tmp;
8672
+ }
8673
+ return shuffled;
8674
+ }
8675
+ function formatTime(seconds) {
8676
+ if (!isFinite(seconds) || seconds < 0) return "0:00";
8677
+ const m = Math.floor(seconds / 60);
8678
+ const s = Math.floor(seconds % 60);
8679
+ return m + ":" + (s < 10 ? "0" : "") + s;
8680
+ }
8681
+ function updateRangeFill(input) {
8682
+ const min = parseFloat(input.min) || 0;
8683
+ const max = parseFloat(input.max) || 1;
8684
+ const val = parseFloat(input.value) || 0;
8685
+ const pct = (val - min) / (max - min) * 100;
8686
+ input.style.setProperty("--fill", pct + "%");
8687
+ 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%)";
8688
+ }
8689
+ function icon(name) {
8690
+ const el = document.createElement("i");
8691
+ el.className = "ph ph-" + name;
8692
+ el.setAttribute("aria-hidden", "true");
8693
+ return el;
8694
+ }
8695
+ const MusicPlayer = {
8696
+ /** @type {Map<HTMLElement, Object>} */
8697
+ instances: /* @__PURE__ */ new Map(),
8698
+ /**
8699
+ * Default options.
8700
+ */
8701
+ defaults: {
8702
+ tracks: [],
8703
+ volume: 0.5,
8704
+ shuffle: false,
8705
+ showProgress: false,
8706
+ showPlaylist: false,
8707
+ autoAdvance: true
8708
+ },
8709
+ /**
8710
+ * Auto-initialize all .vd-music-player / [data-music-player] elements.
8711
+ * Options can be provided via data-music-player-options (JSON string).
8712
+ */
8713
+ init: function() {
8714
+ document.querySelectorAll(".vd-music-player, [data-music-player]").forEach((el) => {
8715
+ if (this.instances.has(el)) return;
8716
+ let opts = {};
8717
+ const attr = el.getAttribute("data-music-player-options");
8718
+ if (attr) {
8719
+ try {
8720
+ opts = JSON.parse(attr);
8721
+ } catch (_) {
8722
+ }
8723
+ }
8724
+ this.initPlayer(el, opts);
8725
+ });
8726
+ },
8727
+ /**
8728
+ * Initialize a single player element.
8729
+ * @param {HTMLElement} container
8730
+ * @param {Object} [options]
8731
+ */
8732
+ initPlayer: function(container, options) {
8733
+ const opts = Object.assign({}, this.defaults, options || {});
8734
+ const rawTracks = Array.isArray(opts.tracks) ? opts.tracks : [];
8735
+ const tracks = rawTracks.filter((t) => t && typeof t.url === "string" && t.url.trim());
8736
+ const trackList = opts.shuffle ? shuffleArray(tracks) : tracks.slice();
8737
+ const state = {
8738
+ tracks: trackList,
8739
+ originalTracks: tracks.slice(),
8740
+ currentIndex: 0,
8741
+ isPlaying: false,
8742
+ volume: Math.max(0, Math.min(1, opts.volume)),
8743
+ shuffle: opts.shuffle,
8744
+ showProgress: opts.showProgress,
8745
+ showPlaylist: opts.showPlaylist,
8746
+ autoAdvance: opts.autoAdvance,
8747
+ audio: null
8748
+ };
8749
+ const audio = new Audio();
8750
+ audio.volume = state.volume;
8751
+ audio.preload = "metadata";
8752
+ state.audio = audio;
8753
+ this._buildDOM(container, state);
8754
+ const refs = {
8755
+ btnPlay: container.querySelector(".vd-music-player-btn-play"),
8756
+ btnPrev: container.querySelector(".vd-music-player-btn-prev"),
8757
+ btnNext: container.querySelector(".vd-music-player-btn-next"),
8758
+ btnShuffle: container.querySelector(".vd-music-player-btn-shuffle"),
8759
+ btnPlaylist: container.querySelector(".vd-music-player-btn-playlist"),
8760
+ trackName: container.querySelector(".vd-music-player-track-name"),
8761
+ volumeSlider: container.querySelector(".vd-music-player-volume-slider"),
8762
+ volumeIcon: container.querySelector(".vd-music-player-volume-icon"),
8763
+ progressBar: container.querySelector(".vd-music-player-progress-bar"),
8764
+ timeElapsed: container.querySelector(".vd-music-player-time-elapsed"),
8765
+ timeDuration: container.querySelector(".vd-music-player-time-duration"),
8766
+ playlistPanel: container.querySelector(".vd-music-player-playlist")
8767
+ };
8768
+ const renderPlayIcon = () => {
8769
+ const btn = refs.btnPlay;
8770
+ if (!btn) return;
8771
+ btn.innerHTML = "";
8772
+ btn.appendChild(icon(state.isPlaying ? "pause" : "play"));
8773
+ btn.setAttribute("aria-label", state.isPlaying ? "Pause" : "Play");
8774
+ btn.classList.toggle("is-active", state.isPlaying);
8775
+ };
8776
+ const renderTrackName = () => {
8777
+ const el = refs.trackName;
8778
+ if (!el) return;
8779
+ const track = state.tracks[state.currentIndex];
8780
+ if (track) {
8781
+ el.textContent = track.name || "Unknown Track";
8782
+ el.classList.remove("is-idle");
8783
+ } else {
8784
+ el.textContent = "No tracks loaded";
8785
+ el.classList.add("is-idle");
8786
+ }
8787
+ };
8788
+ const renderVolumeIcon = () => {
8789
+ const el = refs.volumeIcon;
8790
+ if (!el) return;
8791
+ el.innerHTML = "";
8792
+ const v = state.volume;
8793
+ const name = v === 0 ? "speaker-none" : v < 0.5 ? "speaker-low" : "speaker-high";
8794
+ el.appendChild(icon(name));
8795
+ };
8796
+ const renderShuffleBtn = () => {
8797
+ const btn = refs.btnShuffle;
8798
+ if (!btn) return;
8799
+ btn.classList.toggle("is-active", state.shuffle);
8800
+ btn.setAttribute("aria-pressed", state.shuffle ? "true" : "false");
8801
+ };
8802
+ const renderPlaylistItems = () => {
8803
+ const panel = refs.playlistPanel;
8804
+ if (!panel) return;
8805
+ panel.innerHTML = "";
8806
+ state.tracks.forEach((track, i) => {
8807
+ const item = document.createElement("button");
8808
+ item.className = "vd-music-player-playlist-item" + (i === state.currentIndex ? " is-active" : "");
8809
+ item.type = "button";
8810
+ item.setAttribute("data-index", String(i));
8811
+ item.setAttribute("aria-current", i === state.currentIndex ? "true" : "false");
8812
+ const num = document.createElement("span");
8813
+ num.className = "vd-music-player-playlist-num";
8814
+ num.textContent = String(i + 1);
8815
+ const name = document.createElement("span");
8816
+ name.className = "vd-music-player-playlist-name";
8817
+ name.textContent = track.name || "Track " + (i + 1);
8818
+ item.appendChild(num);
8819
+ item.appendChild(name);
8820
+ panel.appendChild(item);
8821
+ });
8822
+ };
8823
+ const renderProgress = () => {
8824
+ const bar = refs.progressBar;
8825
+ if (!bar || !audio.duration) return;
8826
+ const pct = audio.currentTime / audio.duration * 100;
8827
+ bar.value = String(pct);
8828
+ updateRangeFill(bar);
8829
+ if (refs.timeElapsed) refs.timeElapsed.textContent = formatTime(audio.currentTime);
8830
+ if (refs.timeDuration) refs.timeDuration.textContent = formatTime(audio.duration);
8831
+ };
8832
+ const loadTrack = (index, autoPlay) => {
8833
+ const track = state.tracks[index];
8834
+ if (!track) return;
8835
+ state.currentIndex = index;
8836
+ audio.src = track.url;
8837
+ renderTrackName();
8838
+ renderPlaylistItems();
8839
+ if (refs.progressBar) {
8840
+ refs.progressBar.value = "0";
8841
+ updateRangeFill(refs.progressBar);
8842
+ }
8843
+ if (refs.timeElapsed) refs.timeElapsed.textContent = "0:00";
8844
+ if (refs.timeDuration) refs.timeDuration.textContent = "0:00";
8845
+ container.dispatchEvent(
8846
+ new CustomEvent("musicplayer:trackchange", {
8847
+ bubbles: true,
8848
+ detail: { index, name: track.name, url: track.url }
8849
+ })
8850
+ );
8851
+ if (autoPlay) {
8852
+ audio.play().catch(() => {
8853
+ });
8854
+ }
8855
+ };
8856
+ const cleanupFunctions = [];
8857
+ const onPlay = () => {
8858
+ state.isPlaying = true;
8859
+ renderPlayIcon();
8860
+ container.dispatchEvent(new CustomEvent("musicplayer:play", { bubbles: true }));
8861
+ };
8862
+ const onPause = () => {
8863
+ state.isPlaying = false;
8864
+ renderPlayIcon();
8865
+ container.dispatchEvent(new CustomEvent("musicplayer:pause", { bubbles: true }));
8866
+ };
8867
+ const onEnded = () => {
8868
+ if (state.autoAdvance && state.tracks.length > 1) {
8869
+ const next = (state.currentIndex + 1) % state.tracks.length;
8870
+ loadTrack(next, true);
8871
+ } else {
8872
+ state.isPlaying = false;
8873
+ renderPlayIcon();
8874
+ container.dispatchEvent(new CustomEvent("musicplayer:ended", { bubbles: true }));
8875
+ }
8876
+ };
8877
+ const onTimeUpdate = () => {
8878
+ if (state.showProgress) renderProgress();
8879
+ };
8880
+ const onLoadedMetadata = () => {
8881
+ if (refs.timeDuration) refs.timeDuration.textContent = formatTime(audio.duration);
8882
+ if (refs.progressBar) {
8883
+ refs.progressBar.max = "100";
8884
+ updateRangeFill(refs.progressBar);
8885
+ }
8886
+ };
8887
+ audio.addEventListener("play", onPlay);
8888
+ audio.addEventListener("pause", onPause);
8889
+ audio.addEventListener("ended", onEnded);
8890
+ audio.addEventListener("timeupdate", onTimeUpdate);
8891
+ audio.addEventListener("loadedmetadata", onLoadedMetadata);
8892
+ cleanupFunctions.push(() => {
8893
+ audio.removeEventListener("play", onPlay);
8894
+ audio.removeEventListener("pause", onPause);
8895
+ audio.removeEventListener("ended", onEnded);
8896
+ audio.removeEventListener("timeupdate", onTimeUpdate);
8897
+ audio.removeEventListener("loadedmetadata", onLoadedMetadata);
8898
+ audio.pause();
8899
+ audio.src = "";
8900
+ });
8901
+ if (refs.btnPlay) {
8902
+ const handler = () => {
8903
+ if (!audio.src && state.tracks.length) loadTrack(state.currentIndex, false);
8904
+ if (state.isPlaying) {
8905
+ audio.pause();
8906
+ } else {
8907
+ audio.play().catch(() => {
8908
+ });
8909
+ }
8910
+ };
8911
+ refs.btnPlay.addEventListener("click", handler);
8912
+ cleanupFunctions.push(() => refs.btnPlay.removeEventListener("click", handler));
8913
+ const keyHandler = (e) => {
8914
+ if (e.key === " " || e.key === "Enter") {
8915
+ e.preventDefault();
8916
+ handler();
8917
+ }
8918
+ };
8919
+ refs.btnPlay.addEventListener("keydown", keyHandler);
8920
+ cleanupFunctions.push(() => refs.btnPlay.removeEventListener("keydown", keyHandler));
8921
+ }
8922
+ if (refs.btnPrev) {
8923
+ const handler = () => {
8924
+ if (!state.tracks.length) return;
8925
+ if (audio.currentTime > 3) {
8926
+ audio.currentTime = 0;
8927
+ } else {
8928
+ const prev = state.currentIndex === 0 ? state.tracks.length - 1 : state.currentIndex - 1;
8929
+ loadTrack(prev, state.isPlaying);
8930
+ }
8931
+ };
8932
+ refs.btnPrev.addEventListener("click", handler);
8933
+ cleanupFunctions.push(() => refs.btnPrev.removeEventListener("click", handler));
8934
+ }
8935
+ if (refs.btnNext) {
8936
+ const handler = () => {
8937
+ if (!state.tracks.length) return;
8938
+ const next = (state.currentIndex + 1) % state.tracks.length;
8939
+ loadTrack(next, state.isPlaying);
8940
+ };
8941
+ refs.btnNext.addEventListener("click", handler);
8942
+ cleanupFunctions.push(() => refs.btnNext.removeEventListener("click", handler));
8943
+ }
8944
+ if (refs.btnShuffle) {
8945
+ const handler = () => {
8946
+ state.shuffle = !state.shuffle;
8947
+ if (state.shuffle) {
8948
+ const current = state.tracks[state.currentIndex];
8949
+ state.tracks = shuffleArray(state.tracks);
8950
+ const newIdx = state.tracks.findIndex((t) => t === current);
8951
+ if (newIdx > 0) {
8952
+ state.tracks.splice(newIdx, 1);
8953
+ state.tracks.unshift(current);
8954
+ }
8955
+ state.currentIndex = 0;
8956
+ } else {
8957
+ const current = state.tracks[state.currentIndex];
8958
+ state.tracks = state.originalTracks.slice();
8959
+ state.currentIndex = state.tracks.findIndex((t) => t === current);
8960
+ if (state.currentIndex < 0) state.currentIndex = 0;
8961
+ }
8962
+ renderShuffleBtn();
8963
+ renderPlaylistItems();
8964
+ };
8965
+ refs.btnShuffle.addEventListener("click", handler);
8966
+ cleanupFunctions.push(() => refs.btnShuffle.removeEventListener("click", handler));
8967
+ }
8968
+ if (refs.btnPlaylist) {
8969
+ const handler = () => {
8970
+ const panel = refs.playlistPanel;
8971
+ if (!panel) return;
8972
+ const isOpen = panel.classList.toggle("is-open");
8973
+ refs.btnPlaylist.classList.toggle("is-active", isOpen);
8974
+ refs.btnPlaylist.setAttribute("aria-expanded", isOpen ? "true" : "false");
8975
+ };
8976
+ refs.btnPlaylist.addEventListener("click", handler);
8977
+ cleanupFunctions.push(() => refs.btnPlaylist.removeEventListener("click", handler));
8978
+ }
8979
+ if (refs.volumeSlider) {
8980
+ const handler = (e) => {
8981
+ const v = parseFloat(e.target.value);
8982
+ state.volume = v;
8983
+ audio.volume = v;
8984
+ renderVolumeIcon();
8985
+ updateRangeFill(refs.volumeSlider);
8986
+ container.dispatchEvent(
8987
+ new CustomEvent("musicplayer:volumechange", { bubbles: true, detail: { volume: v } })
8988
+ );
8989
+ };
8990
+ refs.volumeSlider.addEventListener("input", handler);
8991
+ cleanupFunctions.push(() => refs.volumeSlider.removeEventListener("input", handler));
8992
+ updateRangeFill(refs.volumeSlider);
8993
+ }
8994
+ if (refs.progressBar) {
8995
+ const handler = (e) => {
8996
+ if (!audio.duration) return;
8997
+ const pct = parseFloat(e.target.value);
8998
+ audio.currentTime = pct / 100 * audio.duration;
8999
+ updateRangeFill(refs.progressBar);
9000
+ };
9001
+ refs.progressBar.addEventListener("input", handler);
9002
+ cleanupFunctions.push(() => refs.progressBar.removeEventListener("input", handler));
9003
+ }
9004
+ if (refs.playlistPanel) {
9005
+ const panelHandler = (e) => {
9006
+ const item = e.target.closest(".vd-music-player-playlist-item");
9007
+ if (!item) return;
9008
+ const idx = parseInt(item.getAttribute("data-index"), 10);
9009
+ if (!isNaN(idx)) loadTrack(idx, true);
9010
+ };
9011
+ refs.playlistPanel.addEventListener("click", panelHandler);
9012
+ cleanupFunctions.push(
9013
+ () => refs.playlistPanel.removeEventListener("click", panelHandler)
9014
+ );
9015
+ }
9016
+ renderPlayIcon();
9017
+ renderTrackName();
9018
+ renderVolumeIcon();
9019
+ if (opts.showPlaylist) renderPlaylistItems();
9020
+ this.instances.set(container, { state, audio, refs, cleanup: cleanupFunctions });
9021
+ container.setAttribute("data-music-player-initialized", "true");
9022
+ },
9023
+ /* ─── DOM builder ─────────────────────────────────────── */
9024
+ /**
9025
+ * Build the inner DOM structure inside container.
9026
+ * Pre-existing inner content is replaced only if it has no
9027
+ * recognised child elements (allows server-rendered markup).
9028
+ * @param {HTMLElement} container
9029
+ * @param {Object} state
9030
+ */
9031
+ _buildDOM: function(container, state) {
9032
+ if (container.querySelector(".vd-music-player-controls")) return;
9033
+ container.setAttribute("role", "region");
9034
+ container.setAttribute("aria-label", "Music Player");
9035
+ if (state.showProgress) container.classList.add("has-progress");
9036
+ if (state.showPlaylist) container.classList.add("has-playlist");
9037
+ const info = document.createElement("div");
9038
+ info.className = "vd-music-player-info";
9039
+ const iconWrap = document.createElement("span");
9040
+ iconWrap.className = "vd-music-player-icon";
9041
+ iconWrap.setAttribute("aria-hidden", "true");
9042
+ iconWrap.appendChild(icon("music-note"));
9043
+ const trackName = document.createElement("span");
9044
+ trackName.className = "vd-music-player-track-name";
9045
+ trackName.setAttribute("aria-live", "polite");
9046
+ trackName.setAttribute("aria-atomic", "true");
9047
+ info.appendChild(iconWrap);
9048
+ info.appendChild(trackName);
9049
+ container.appendChild(info);
9050
+ const controls = document.createElement("div");
9051
+ controls.className = "vd-music-player-controls";
9052
+ controls.setAttribute("role", "group");
9053
+ controls.setAttribute("aria-label", "Playback controls");
9054
+ const btnPrev = document.createElement("button");
9055
+ btnPrev.type = "button";
9056
+ btnPrev.className = "vd-music-player-btn vd-music-player-btn-prev";
9057
+ btnPrev.setAttribute("aria-label", "Previous track");
9058
+ btnPrev.appendChild(icon("skip-back"));
9059
+ const btnPlay = document.createElement("button");
9060
+ btnPlay.type = "button";
9061
+ btnPlay.className = "vd-music-player-btn vd-music-player-btn-play";
9062
+ btnPlay.setAttribute("aria-label", "Play");
9063
+ btnPlay.appendChild(icon("play"));
9064
+ const btnNext = document.createElement("button");
9065
+ btnNext.type = "button";
9066
+ btnNext.className = "vd-music-player-btn vd-music-player-btn-next";
9067
+ btnNext.setAttribute("aria-label", "Next track");
9068
+ btnNext.appendChild(icon("skip-forward"));
9069
+ controls.appendChild(btnPrev);
9070
+ controls.appendChild(btnPlay);
9071
+ controls.appendChild(btnNext);
9072
+ if (state.showPlaylist || state.shuffle !== void 0) {
9073
+ const btnShuffle = document.createElement("button");
9074
+ btnShuffle.type = "button";
9075
+ btnShuffle.className = "vd-music-player-btn vd-music-player-btn-shuffle";
9076
+ btnShuffle.setAttribute("aria-label", "Shuffle");
9077
+ btnShuffle.setAttribute("aria-pressed", state.shuffle ? "true" : "false");
9078
+ btnShuffle.appendChild(icon("shuffle"));
9079
+ controls.appendChild(btnShuffle);
9080
+ }
9081
+ const spacer = document.createElement("span");
9082
+ spacer.className = "vd-music-player-spacer";
9083
+ spacer.setAttribute("aria-hidden", "true");
9084
+ controls.appendChild(spacer);
9085
+ const volumeWrap = document.createElement("div");
9086
+ volumeWrap.className = "vd-music-player-volume";
9087
+ const volumeIcon = document.createElement("span");
9088
+ volumeIcon.className = "vd-music-player-volume-icon";
9089
+ volumeIcon.setAttribute("aria-hidden", "true");
9090
+ const volumeSlider = document.createElement("input");
9091
+ volumeSlider.type = "range";
9092
+ volumeSlider.className = "vd-music-player-volume-slider";
9093
+ volumeSlider.min = "0";
9094
+ volumeSlider.max = "1";
9095
+ volumeSlider.step = "0.01";
9096
+ volumeSlider.value = String(state.volume);
9097
+ volumeSlider.setAttribute("aria-label", "Volume");
9098
+ volumeWrap.appendChild(volumeIcon);
9099
+ volumeWrap.appendChild(volumeSlider);
9100
+ controls.appendChild(volumeWrap);
9101
+ if (state.showPlaylist) {
9102
+ const btnPlaylist = document.createElement("button");
9103
+ btnPlaylist.type = "button";
9104
+ btnPlaylist.className = "vd-music-player-btn vd-music-player-btn-playlist";
9105
+ btnPlaylist.setAttribute("aria-label", "Show playlist");
9106
+ btnPlaylist.setAttribute("aria-expanded", "false");
9107
+ btnPlaylist.appendChild(icon("playlist"));
9108
+ controls.appendChild(btnPlaylist);
9109
+ }
9110
+ container.appendChild(controls);
9111
+ if (state.showProgress) {
9112
+ const progressRow = document.createElement("div");
9113
+ progressRow.className = "vd-music-player-progress";
9114
+ const timeElapsed = document.createElement("span");
9115
+ timeElapsed.className = "vd-music-player-time vd-music-player-time-elapsed";
9116
+ timeElapsed.textContent = "0:00";
9117
+ timeElapsed.setAttribute("aria-hidden", "true");
9118
+ const progressBar = document.createElement("input");
9119
+ progressBar.type = "range";
9120
+ progressBar.className = "vd-music-player-progress-bar";
9121
+ progressBar.min = "0";
9122
+ progressBar.max = "100";
9123
+ progressBar.step = "0.1";
9124
+ progressBar.value = "0";
9125
+ progressBar.setAttribute("aria-label", "Seek");
9126
+ const timeDuration = document.createElement("span");
9127
+ timeDuration.className = "vd-music-player-time vd-music-player-time-duration";
9128
+ timeDuration.textContent = "0:00";
9129
+ timeDuration.setAttribute("aria-hidden", "true");
9130
+ progressRow.appendChild(timeElapsed);
9131
+ progressRow.appendChild(progressBar);
9132
+ progressRow.appendChild(timeDuration);
9133
+ container.appendChild(progressRow);
9134
+ }
9135
+ if (state.showPlaylist) {
9136
+ const playlist = document.createElement("div");
9137
+ playlist.className = "vd-music-player-playlist";
9138
+ playlist.setAttribute("aria-label", "Playlist");
9139
+ container.appendChild(playlist);
9140
+ }
9141
+ },
9142
+ /* ─── Public API ──────────────────────────────────────── */
9143
+ /**
9144
+ * @param {HTMLElement} container
9145
+ */
9146
+ play: function(container) {
9147
+ const inst = this.instances.get(container);
9148
+ if (!inst) return;
9149
+ if (!inst.audio.src && inst.state.tracks.length) {
9150
+ inst.audio.src = inst.state.tracks[inst.state.currentIndex].url;
9151
+ }
9152
+ inst.audio.play().catch(() => {
9153
+ });
9154
+ },
9155
+ /**
9156
+ * @param {HTMLElement} container
9157
+ */
9158
+ pause: function(container) {
9159
+ const inst = this.instances.get(container);
9160
+ if (inst) inst.audio.pause();
9161
+ },
9162
+ /**
9163
+ * @param {HTMLElement} container
9164
+ */
9165
+ toggle: function(container) {
9166
+ const inst = this.instances.get(container);
9167
+ if (!inst) return;
9168
+ if (inst.state.isPlaying) {
9169
+ this.pause(container);
9170
+ } else {
9171
+ this.play(container);
9172
+ }
9173
+ },
9174
+ /**
9175
+ * @param {HTMLElement} container
9176
+ */
9177
+ next: function(container) {
9178
+ const inst = this.instances.get(container);
9179
+ if (!inst || !inst.state.tracks.length) return;
9180
+ const next = (inst.state.currentIndex + 1) % inst.state.tracks.length;
9181
+ this._loadTrack(inst, next, inst.state.isPlaying);
9182
+ },
9183
+ /**
9184
+ * @param {HTMLElement} container
9185
+ */
9186
+ previous: function(container) {
9187
+ const inst = this.instances.get(container);
9188
+ if (!inst || !inst.state.tracks.length) return;
9189
+ const len = inst.state.tracks.length;
9190
+ const prev = (inst.state.currentIndex - 1 + len) % len;
9191
+ this._loadTrack(inst, prev, inst.state.isPlaying);
9192
+ },
9193
+ /**
9194
+ * @param {HTMLElement} container
9195
+ * @param {number} value - 0 to 1
9196
+ */
9197
+ setVolume: function(container, value) {
9198
+ const inst = this.instances.get(container);
9199
+ if (!inst) return;
9200
+ const v = Math.max(0, Math.min(1, value));
9201
+ inst.state.volume = v;
9202
+ inst.audio.volume = v;
9203
+ if (inst.refs.volumeSlider) {
9204
+ inst.refs.volumeSlider.value = String(v);
9205
+ updateRangeFill(inst.refs.volumeSlider);
9206
+ }
9207
+ container.dispatchEvent(
9208
+ new CustomEvent("musicplayer:volumechange", { bubbles: true, detail: { volume: v } })
9209
+ );
9210
+ },
9211
+ /**
9212
+ * @param {HTMLElement} container
9213
+ * @param {number} index - Track index
9214
+ */
9215
+ setTrack: function(container, index) {
9216
+ const inst = this.instances.get(container);
9217
+ if (!inst) return;
9218
+ this._loadTrack(inst, index, inst.state.isPlaying);
9219
+ },
9220
+ /**
9221
+ * Shuffle or un-shuffle the track list.
9222
+ * @param {HTMLElement} container
9223
+ */
9224
+ shuffle: function(container) {
9225
+ const inst = this.instances.get(container);
9226
+ if (!inst || !inst.refs.btnShuffle) return;
9227
+ inst.refs.btnShuffle.click();
9228
+ },
9229
+ /**
9230
+ * Return a shallow copy of the current player state.
9231
+ * @param {HTMLElement} container
9232
+ * @returns {Object|null}
9233
+ */
9234
+ getState: function(container) {
9235
+ const inst = this.instances.get(container);
9236
+ if (!inst) return null;
9237
+ const s = inst.state;
9238
+ return {
9239
+ isPlaying: s.isPlaying,
9240
+ currentIndex: s.currentIndex,
9241
+ currentTrack: s.tracks[s.currentIndex] || null,
9242
+ volume: s.volume,
9243
+ shuffle: s.shuffle,
9244
+ tracks: s.tracks.slice()
9245
+ };
9246
+ },
9247
+ /**
9248
+ * Stop playback, clean up listeners, remove instance.
9249
+ * @param {HTMLElement} container
9250
+ */
9251
+ destroy: function(container) {
9252
+ const inst = this.instances.get(container);
9253
+ if (!inst) return;
9254
+ inst.cleanup.forEach((fn) => fn());
9255
+ this.instances.delete(container);
9256
+ container.removeAttribute("data-music-player-initialized");
9257
+ },
9258
+ /**
9259
+ * Destroy all instances.
9260
+ */
9261
+ destroyAll: function() {
9262
+ this.instances.forEach((_, container) => this.destroy(container));
9263
+ },
9264
+ /* ─── Internal helpers ────────────────────────────────── */
9265
+ /**
9266
+ * Load track by index on an already-initialised instance object.
9267
+ * @param {Object} inst
9268
+ * @param {number} index
9269
+ * @param {boolean} autoPlay
9270
+ */
9271
+ _loadTrack: function(inst, index, autoPlay) {
9272
+ const track = inst.state.tracks[index];
9273
+ if (!track) return;
9274
+ const container = this._containerOf(inst);
9275
+ inst.state.currentIndex = index;
9276
+ inst.audio.src = track.url;
9277
+ if (inst.refs.trackName) {
9278
+ inst.refs.trackName.textContent = track.name || "Unknown Track";
9279
+ inst.refs.trackName.classList.remove("is-idle");
9280
+ }
9281
+ if (inst.refs.playlistPanel) {
9282
+ inst.refs.playlistPanel.querySelectorAll(".vd-music-player-playlist-item").forEach((item, i) => {
9283
+ const active = i === index;
9284
+ item.classList.toggle("is-active", active);
9285
+ item.setAttribute("aria-current", active ? "true" : "false");
9286
+ });
9287
+ }
9288
+ if (inst.refs.progressBar) {
9289
+ inst.refs.progressBar.value = "0";
9290
+ updateRangeFill(inst.refs.progressBar);
9291
+ }
9292
+ if (inst.refs.timeElapsed) inst.refs.timeElapsed.textContent = "0:00";
9293
+ if (inst.refs.timeDuration) inst.refs.timeDuration.textContent = "0:00";
9294
+ if (container) {
9295
+ container.dispatchEvent(
9296
+ new CustomEvent("musicplayer:trackchange", {
9297
+ bubbles: true,
9298
+ detail: { index, name: track.name, url: track.url }
9299
+ })
9300
+ );
9301
+ }
9302
+ if (autoPlay) inst.audio.play().catch(() => {
9303
+ });
9304
+ },
9305
+ /**
9306
+ * Reverse-lookup the container element for a given instance object.
9307
+ * @param {Object} inst
9308
+ * @returns {HTMLElement|null}
9309
+ */
9310
+ _containerOf: function(inst) {
9311
+ for (const [container, i] of this.instances) {
9312
+ if (i === inst) return container;
9313
+ }
9314
+ return null;
9315
+ }
9316
+ };
9317
+ if (typeof window.Vanduo !== "undefined") {
9318
+ window.Vanduo.register("musicPlayer", MusicPlayer);
9319
+ }
9320
+ window.VanduoMusicPlayer = MusicPlayer;
9321
+ })();
9322
+
8568
9323
  // js/index.js
8569
9324
  var Vanduo = window.Vanduo;
8570
9325
  var index_default = Vanduo;