@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.
- package/README.md +13 -12
- package/css/components/music-player.css +578 -0
- package/css/components/navbar.css +5 -0
- package/css/vanduo.css +1 -0
- package/dist/build-info.json +3 -3
- package/dist/vanduo.cjs.js +729 -44
- package/dist/vanduo.cjs.js.map +3 -3
- package/dist/vanduo.cjs.min.js +5 -5
- package/dist/vanduo.cjs.min.js.map +4 -4
- package/dist/vanduo.css +512 -1
- package/dist/vanduo.css.map +1 -1
- package/dist/vanduo.esm.js +729 -44
- package/dist/vanduo.esm.js.map +3 -3
- package/dist/vanduo.esm.min.js +5 -5
- package/dist/vanduo.esm.min.js.map +4 -4
- package/dist/vanduo.js +729 -44
- package/dist/vanduo.js.map +3 -3
- package/dist/vanduo.min.css +2 -2
- package/dist/vanduo.min.css.map +1 -1
- package/dist/vanduo.min.js +5 -5
- package/dist/vanduo.min.js.map +4 -4
- package/js/components/code-snippet.js +5 -4
- package/js/components/dropdown.js +9 -9
- package/js/components/image-box.js +7 -1
- package/js/components/modals.js +18 -10
- package/js/components/music-player.js +848 -0
- package/js/components/navbar.js +3 -3
- package/js/components/select.js +15 -13
- package/js/components/suggest.js +14 -1
- package/js/components/theme-customizer.js +0 -1
- package/js/components/validate.js +14 -3
- package/js/index.js +1 -0
- package/js/utils/helpers.js +7 -3
- package/package.json +2 -2
|
@@ -0,0 +1,848 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vanduo Framework - Music Player Component
|
|
3
|
+
* HTML5 Audio-based music player with transport controls, volume,
|
|
4
|
+
* and optional shuffle, seek bar, and playlist features.
|
|
5
|
+
*
|
|
6
|
+
* Options (passed to MusicPlayer.init or data-music-player-options):
|
|
7
|
+
* tracks {Array} - [{name, url}] — required
|
|
8
|
+
* volume {number} - Initial volume 0–1 (default 0.5)
|
|
9
|
+
* shuffle {boolean} - Shuffle on init (default false)
|
|
10
|
+
* showProgress {boolean} - Show seek/progress bar (default false)
|
|
11
|
+
* showPlaylist {boolean} - Show expandable playlist panel (default false)
|
|
12
|
+
* autoAdvance {boolean} - Auto-play next track on end (default true)
|
|
13
|
+
*
|
|
14
|
+
* Custom events (all bubble, dispatched on container):
|
|
15
|
+
* musicplayer:play — playback started
|
|
16
|
+
* musicplayer:pause — playback paused
|
|
17
|
+
* musicplayer:trackchange — detail: { index, name, url }
|
|
18
|
+
* musicplayer:volumechange — detail: { volume }
|
|
19
|
+
* musicplayer:ended — last track ended (autoAdvance=false only)
|
|
20
|
+
*
|
|
21
|
+
* Programmatic API:
|
|
22
|
+
* MusicPlayer.init(container?, options?)
|
|
23
|
+
* MusicPlayer.play(container)
|
|
24
|
+
* MusicPlayer.pause(container)
|
|
25
|
+
* MusicPlayer.toggle(container)
|
|
26
|
+
* MusicPlayer.next(container)
|
|
27
|
+
* MusicPlayer.previous(container)
|
|
28
|
+
* MusicPlayer.setVolume(container, value)
|
|
29
|
+
* MusicPlayer.setTrack(container, index)
|
|
30
|
+
* MusicPlayer.shuffle(container)
|
|
31
|
+
* MusicPlayer.getState(container)
|
|
32
|
+
* MusicPlayer.destroy(container)
|
|
33
|
+
* MusicPlayer.destroyAll()
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
(function () {
|
|
37
|
+
'use strict';
|
|
38
|
+
|
|
39
|
+
/* ─── Helpers ─────────────────────────────────────────── */
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Fisher-Yates shuffle (returns new array).
|
|
43
|
+
* @param {Array} arr
|
|
44
|
+
* @returns {Array}
|
|
45
|
+
*/
|
|
46
|
+
function shuffleArray(arr) {
|
|
47
|
+
const shuffled = arr.slice();
|
|
48
|
+
for (let i = shuffled.length - 1; i > 0; i--) {
|
|
49
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
50
|
+
const tmp = shuffled[i];
|
|
51
|
+
shuffled[i] = shuffled[j];
|
|
52
|
+
shuffled[j] = tmp;
|
|
53
|
+
}
|
|
54
|
+
return shuffled;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Format seconds as m:ss.
|
|
59
|
+
* @param {number} seconds
|
|
60
|
+
* @returns {string}
|
|
61
|
+
*/
|
|
62
|
+
function formatTime(seconds) {
|
|
63
|
+
if (!isFinite(seconds) || seconds < 0) return '0:00';
|
|
64
|
+
const m = Math.floor(seconds / 60);
|
|
65
|
+
const s = Math.floor(seconds % 60);
|
|
66
|
+
return m + ':' + (s < 10 ? '0' : '') + s;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Set CSS background-size on a range input to visually fill the track.
|
|
71
|
+
* @param {HTMLInputElement} input
|
|
72
|
+
*/
|
|
73
|
+
function updateRangeFill(input) {
|
|
74
|
+
const min = parseFloat(input.min) || 0;
|
|
75
|
+
const max = parseFloat(input.max) || 1;
|
|
76
|
+
const val = parseFloat(input.value) || 0;
|
|
77
|
+
const pct = ((val - min) / (max - min)) * 100;
|
|
78
|
+
input.style.setProperty('--fill', pct + '%');
|
|
79
|
+
// Fallback inline gradient for browsers without ::-moz-range-progress
|
|
80
|
+
input.style.backgroundImage =
|
|
81
|
+
'linear-gradient(to right, var(--music-player-track-fill, currentColor) 0%, ' +
|
|
82
|
+
'var(--music-player-track-fill, currentColor) ' + pct + '%, ' +
|
|
83
|
+
'var(--music-player-track-bg, #ccc) ' + pct + '%, ' +
|
|
84
|
+
'var(--music-player-track-bg, #ccc) 100%)';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/* ─── Phosphor icon helper (matches framework icon style) ─ */
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Return an <i class="ph ph-{name}"> element (Phosphor icon).
|
|
91
|
+
* @param {string} name
|
|
92
|
+
* @returns {HTMLElement}
|
|
93
|
+
*/
|
|
94
|
+
function icon(name) {
|
|
95
|
+
const el = document.createElement('i');
|
|
96
|
+
el.className = 'ph ph-' + name;
|
|
97
|
+
el.setAttribute('aria-hidden', 'true');
|
|
98
|
+
return el;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/* ─── Component ───────────────────────────────────────── */
|
|
102
|
+
|
|
103
|
+
const MusicPlayer = {
|
|
104
|
+
/** @type {Map<HTMLElement, Object>} */
|
|
105
|
+
instances: new Map(),
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Default options.
|
|
109
|
+
*/
|
|
110
|
+
defaults: {
|
|
111
|
+
tracks: [],
|
|
112
|
+
volume: 0.5,
|
|
113
|
+
shuffle: false,
|
|
114
|
+
showProgress: false,
|
|
115
|
+
showPlaylist: false,
|
|
116
|
+
autoAdvance: true,
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Auto-initialize all .vd-music-player / [data-music-player] elements.
|
|
121
|
+
* Options can be provided via data-music-player-options (JSON string).
|
|
122
|
+
*/
|
|
123
|
+
init: function () {
|
|
124
|
+
document.querySelectorAll('.vd-music-player, [data-music-player]').forEach((el) => {
|
|
125
|
+
if (this.instances.has(el)) return;
|
|
126
|
+
|
|
127
|
+
let opts = {};
|
|
128
|
+
const attr = el.getAttribute('data-music-player-options');
|
|
129
|
+
if (attr) {
|
|
130
|
+
try { opts = JSON.parse(attr); } catch (_) { /* ignore malformed JSON */ }
|
|
131
|
+
}
|
|
132
|
+
this.initPlayer(el, opts);
|
|
133
|
+
});
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Initialize a single player element.
|
|
138
|
+
* @param {HTMLElement} container
|
|
139
|
+
* @param {Object} [options]
|
|
140
|
+
*/
|
|
141
|
+
initPlayer: function (container, options) {
|
|
142
|
+
const opts = Object.assign({}, this.defaults, options || {});
|
|
143
|
+
|
|
144
|
+
// Validate and normalise tracks
|
|
145
|
+
const rawTracks = Array.isArray(opts.tracks) ? opts.tracks : [];
|
|
146
|
+
const tracks = rawTracks.filter((t) => t && typeof t.url === 'string' && t.url.trim());
|
|
147
|
+
|
|
148
|
+
// Build shuffled working copy without mutating opts
|
|
149
|
+
const trackList = opts.shuffle ? shuffleArray(tracks) : tracks.slice();
|
|
150
|
+
|
|
151
|
+
/* ── State ─────────────────────────────────────────── */
|
|
152
|
+
const state = {
|
|
153
|
+
tracks: trackList,
|
|
154
|
+
originalTracks: tracks.slice(),
|
|
155
|
+
currentIndex: 0,
|
|
156
|
+
isPlaying: false,
|
|
157
|
+
volume: Math.max(0, Math.min(1, opts.volume)),
|
|
158
|
+
shuffle: opts.shuffle,
|
|
159
|
+
showProgress: opts.showProgress,
|
|
160
|
+
showPlaylist: opts.showPlaylist,
|
|
161
|
+
autoAdvance: opts.autoAdvance,
|
|
162
|
+
audio: null,
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
/* ── Audio element ─────────────────────────────────── */
|
|
166
|
+
const audio = new Audio();
|
|
167
|
+
audio.volume = state.volume;
|
|
168
|
+
audio.preload = 'metadata';
|
|
169
|
+
state.audio = audio;
|
|
170
|
+
|
|
171
|
+
/* ── Build DOM ─────────────────────────────────────── */
|
|
172
|
+
this._buildDOM(container, state);
|
|
173
|
+
|
|
174
|
+
// Grab references after DOM build
|
|
175
|
+
const refs = {
|
|
176
|
+
btnPlay: container.querySelector('.vd-music-player-btn-play'),
|
|
177
|
+
btnPrev: container.querySelector('.vd-music-player-btn-prev'),
|
|
178
|
+
btnNext: container.querySelector('.vd-music-player-btn-next'),
|
|
179
|
+
btnShuffle: container.querySelector('.vd-music-player-btn-shuffle'),
|
|
180
|
+
btnPlaylist: container.querySelector('.vd-music-player-btn-playlist'),
|
|
181
|
+
trackName: container.querySelector('.vd-music-player-track-name'),
|
|
182
|
+
volumeSlider: container.querySelector('.vd-music-player-volume-slider'),
|
|
183
|
+
volumeIcon: container.querySelector('.vd-music-player-volume-icon'),
|
|
184
|
+
progressBar: container.querySelector('.vd-music-player-progress-bar'),
|
|
185
|
+
timeElapsed: container.querySelector('.vd-music-player-time-elapsed'),
|
|
186
|
+
timeDuration: container.querySelector('.vd-music-player-time-duration'),
|
|
187
|
+
playlistPanel: container.querySelector('.vd-music-player-playlist'),
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
/* ── Internal render helpers ───────────────────────── */
|
|
191
|
+
|
|
192
|
+
const renderPlayIcon = () => {
|
|
193
|
+
const btn = refs.btnPlay;
|
|
194
|
+
if (!btn) return;
|
|
195
|
+
btn.innerHTML = '';
|
|
196
|
+
btn.appendChild(icon(state.isPlaying ? 'pause' : 'play'));
|
|
197
|
+
btn.setAttribute('aria-label', state.isPlaying ? 'Pause' : 'Play');
|
|
198
|
+
btn.classList.toggle('is-active', state.isPlaying);
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const renderTrackName = () => {
|
|
202
|
+
const el = refs.trackName;
|
|
203
|
+
if (!el) return;
|
|
204
|
+
const track = state.tracks[state.currentIndex];
|
|
205
|
+
if (track) {
|
|
206
|
+
el.textContent = track.name || 'Unknown Track';
|
|
207
|
+
el.classList.remove('is-idle');
|
|
208
|
+
} else {
|
|
209
|
+
el.textContent = 'No tracks loaded';
|
|
210
|
+
el.classList.add('is-idle');
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const renderVolumeIcon = () => {
|
|
215
|
+
const el = refs.volumeIcon;
|
|
216
|
+
if (!el) return;
|
|
217
|
+
el.innerHTML = '';
|
|
218
|
+
const v = state.volume;
|
|
219
|
+
const name = v === 0 ? 'speaker-none' : v < 0.5 ? 'speaker-low' : 'speaker-high';
|
|
220
|
+
el.appendChild(icon(name));
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const renderShuffleBtn = () => {
|
|
224
|
+
const btn = refs.btnShuffle;
|
|
225
|
+
if (!btn) return;
|
|
226
|
+
btn.classList.toggle('is-active', state.shuffle);
|
|
227
|
+
btn.setAttribute('aria-pressed', state.shuffle ? 'true' : 'false');
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const renderPlaylistItems = () => {
|
|
231
|
+
const panel = refs.playlistPanel;
|
|
232
|
+
if (!panel) return;
|
|
233
|
+
panel.innerHTML = '';
|
|
234
|
+
state.tracks.forEach((track, i) => {
|
|
235
|
+
const item = document.createElement('button');
|
|
236
|
+
item.className =
|
|
237
|
+
'vd-music-player-playlist-item' + (i === state.currentIndex ? ' is-active' : '');
|
|
238
|
+
item.type = 'button';
|
|
239
|
+
item.setAttribute('data-index', String(i));
|
|
240
|
+
item.setAttribute('aria-current', i === state.currentIndex ? 'true' : 'false');
|
|
241
|
+
|
|
242
|
+
const num = document.createElement('span');
|
|
243
|
+
num.className = 'vd-music-player-playlist-num';
|
|
244
|
+
num.textContent = String(i + 1);
|
|
245
|
+
|
|
246
|
+
const name = document.createElement('span');
|
|
247
|
+
name.className = 'vd-music-player-playlist-name';
|
|
248
|
+
name.textContent = track.name || 'Track ' + (i + 1);
|
|
249
|
+
|
|
250
|
+
item.appendChild(num);
|
|
251
|
+
item.appendChild(name);
|
|
252
|
+
panel.appendChild(item);
|
|
253
|
+
});
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const renderProgress = () => {
|
|
257
|
+
const bar = refs.progressBar;
|
|
258
|
+
if (!bar || !audio.duration) return;
|
|
259
|
+
const pct = (audio.currentTime / audio.duration) * 100;
|
|
260
|
+
bar.value = String(pct);
|
|
261
|
+
updateRangeFill(bar);
|
|
262
|
+
if (refs.timeElapsed) refs.timeElapsed.textContent = formatTime(audio.currentTime);
|
|
263
|
+
if (refs.timeDuration) refs.timeDuration.textContent = formatTime(audio.duration);
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
/* ── Load track ─────────────────────────────────────── */
|
|
267
|
+
|
|
268
|
+
const loadTrack = (index, autoPlay) => {
|
|
269
|
+
const track = state.tracks[index];
|
|
270
|
+
if (!track) return;
|
|
271
|
+
state.currentIndex = index;
|
|
272
|
+
audio.src = track.url;
|
|
273
|
+
renderTrackName();
|
|
274
|
+
renderPlaylistItems();
|
|
275
|
+
|
|
276
|
+
// Reset progress
|
|
277
|
+
if (refs.progressBar) {
|
|
278
|
+
refs.progressBar.value = '0';
|
|
279
|
+
updateRangeFill(refs.progressBar);
|
|
280
|
+
}
|
|
281
|
+
if (refs.timeElapsed) refs.timeElapsed.textContent = '0:00';
|
|
282
|
+
if (refs.timeDuration) refs.timeDuration.textContent = '0:00';
|
|
283
|
+
|
|
284
|
+
container.dispatchEvent(
|
|
285
|
+
new CustomEvent('musicplayer:trackchange', {
|
|
286
|
+
bubbles: true,
|
|
287
|
+
detail: { index, name: track.name, url: track.url },
|
|
288
|
+
})
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
if (autoPlay) {
|
|
292
|
+
audio.play().catch(() => { /* browser may block autoplay */ });
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
/* ── Audio event listeners ─────────────────────────── */
|
|
297
|
+
const cleanupFunctions = [];
|
|
298
|
+
|
|
299
|
+
const onPlay = () => {
|
|
300
|
+
state.isPlaying = true;
|
|
301
|
+
renderPlayIcon();
|
|
302
|
+
container.dispatchEvent(new CustomEvent('musicplayer:play', { bubbles: true }));
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
const onPause = () => {
|
|
306
|
+
state.isPlaying = false;
|
|
307
|
+
renderPlayIcon();
|
|
308
|
+
container.dispatchEvent(new CustomEvent('musicplayer:pause', { bubbles: true }));
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
const onEnded = () => {
|
|
312
|
+
if (state.autoAdvance && state.tracks.length > 1) {
|
|
313
|
+
const next = (state.currentIndex + 1) % state.tracks.length;
|
|
314
|
+
loadTrack(next, true);
|
|
315
|
+
} else {
|
|
316
|
+
state.isPlaying = false;
|
|
317
|
+
renderPlayIcon();
|
|
318
|
+
container.dispatchEvent(new CustomEvent('musicplayer:ended', { bubbles: true }));
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
const onTimeUpdate = () => {
|
|
323
|
+
if (state.showProgress) renderProgress();
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
const onLoadedMetadata = () => {
|
|
327
|
+
if (refs.timeDuration) refs.timeDuration.textContent = formatTime(audio.duration);
|
|
328
|
+
if (refs.progressBar) {
|
|
329
|
+
refs.progressBar.max = '100';
|
|
330
|
+
updateRangeFill(refs.progressBar);
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
audio.addEventListener('play', onPlay);
|
|
335
|
+
audio.addEventListener('pause', onPause);
|
|
336
|
+
audio.addEventListener('ended', onEnded);
|
|
337
|
+
audio.addEventListener('timeupdate', onTimeUpdate);
|
|
338
|
+
audio.addEventListener('loadedmetadata', onLoadedMetadata);
|
|
339
|
+
cleanupFunctions.push(() => {
|
|
340
|
+
audio.removeEventListener('play', onPlay);
|
|
341
|
+
audio.removeEventListener('pause', onPause);
|
|
342
|
+
audio.removeEventListener('ended', onEnded);
|
|
343
|
+
audio.removeEventListener('timeupdate', onTimeUpdate);
|
|
344
|
+
audio.removeEventListener('loadedmetadata', onLoadedMetadata);
|
|
345
|
+
audio.pause();
|
|
346
|
+
audio.src = '';
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
/* ── Control button listeners ──────────────────────── */
|
|
350
|
+
|
|
351
|
+
if (refs.btnPlay) {
|
|
352
|
+
const handler = () => {
|
|
353
|
+
if (!audio.src && state.tracks.length) loadTrack(state.currentIndex, false);
|
|
354
|
+
if (state.isPlaying) {
|
|
355
|
+
audio.pause();
|
|
356
|
+
} else {
|
|
357
|
+
audio.play().catch(() => {});
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
refs.btnPlay.addEventListener('click', handler);
|
|
361
|
+
cleanupFunctions.push(() => refs.btnPlay.removeEventListener('click', handler));
|
|
362
|
+
|
|
363
|
+
// Keyboard: Space / Enter (already native for <button>; guard for edge cases)
|
|
364
|
+
const keyHandler = (e) => {
|
|
365
|
+
if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); handler(); }
|
|
366
|
+
};
|
|
367
|
+
refs.btnPlay.addEventListener('keydown', keyHandler);
|
|
368
|
+
cleanupFunctions.push(() => refs.btnPlay.removeEventListener('keydown', keyHandler));
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (refs.btnPrev) {
|
|
372
|
+
const handler = () => {
|
|
373
|
+
if (!state.tracks.length) return;
|
|
374
|
+
// If more than 3s into track, restart; otherwise go to previous
|
|
375
|
+
if (audio.currentTime > 3) {
|
|
376
|
+
audio.currentTime = 0;
|
|
377
|
+
} else {
|
|
378
|
+
const prev =
|
|
379
|
+
state.currentIndex === 0 ? state.tracks.length - 1 : state.currentIndex - 1;
|
|
380
|
+
loadTrack(prev, state.isPlaying);
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
refs.btnPrev.addEventListener('click', handler);
|
|
384
|
+
cleanupFunctions.push(() => refs.btnPrev.removeEventListener('click', handler));
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (refs.btnNext) {
|
|
388
|
+
const handler = () => {
|
|
389
|
+
if (!state.tracks.length) return;
|
|
390
|
+
const next = (state.currentIndex + 1) % state.tracks.length;
|
|
391
|
+
loadTrack(next, state.isPlaying);
|
|
392
|
+
};
|
|
393
|
+
refs.btnNext.addEventListener('click', handler);
|
|
394
|
+
cleanupFunctions.push(() => refs.btnNext.removeEventListener('click', handler));
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (refs.btnShuffle) {
|
|
398
|
+
const handler = () => {
|
|
399
|
+
state.shuffle = !state.shuffle;
|
|
400
|
+
if (state.shuffle) {
|
|
401
|
+
const current = state.tracks[state.currentIndex];
|
|
402
|
+
state.tracks = shuffleArray(state.tracks);
|
|
403
|
+
// Keep current track at position 0 of the new order
|
|
404
|
+
const newIdx = state.tracks.findIndex((t) => t === current);
|
|
405
|
+
if (newIdx > 0) {
|
|
406
|
+
state.tracks.splice(newIdx, 1);
|
|
407
|
+
state.tracks.unshift(current);
|
|
408
|
+
}
|
|
409
|
+
state.currentIndex = 0;
|
|
410
|
+
} else {
|
|
411
|
+
// Restore original order, keep same track
|
|
412
|
+
const current = state.tracks[state.currentIndex];
|
|
413
|
+
state.tracks = state.originalTracks.slice();
|
|
414
|
+
state.currentIndex = state.tracks.findIndex((t) => t === current);
|
|
415
|
+
if (state.currentIndex < 0) state.currentIndex = 0;
|
|
416
|
+
}
|
|
417
|
+
renderShuffleBtn();
|
|
418
|
+
renderPlaylistItems();
|
|
419
|
+
};
|
|
420
|
+
refs.btnShuffle.addEventListener('click', handler);
|
|
421
|
+
cleanupFunctions.push(() => refs.btnShuffle.removeEventListener('click', handler));
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (refs.btnPlaylist) {
|
|
425
|
+
const handler = () => {
|
|
426
|
+
const panel = refs.playlistPanel;
|
|
427
|
+
if (!panel) return;
|
|
428
|
+
const isOpen = panel.classList.toggle('is-open');
|
|
429
|
+
refs.btnPlaylist.classList.toggle('is-active', isOpen);
|
|
430
|
+
refs.btnPlaylist.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
|
431
|
+
};
|
|
432
|
+
refs.btnPlaylist.addEventListener('click', handler);
|
|
433
|
+
cleanupFunctions.push(() => refs.btnPlaylist.removeEventListener('click', handler));
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (refs.volumeSlider) {
|
|
437
|
+
const handler = (e) => {
|
|
438
|
+
const v = parseFloat(e.target.value);
|
|
439
|
+
state.volume = v;
|
|
440
|
+
audio.volume = v;
|
|
441
|
+
renderVolumeIcon();
|
|
442
|
+
updateRangeFill(refs.volumeSlider);
|
|
443
|
+
container.dispatchEvent(
|
|
444
|
+
new CustomEvent('musicplayer:volumechange', { bubbles: true, detail: { volume: v } })
|
|
445
|
+
);
|
|
446
|
+
};
|
|
447
|
+
refs.volumeSlider.addEventListener('input', handler);
|
|
448
|
+
cleanupFunctions.push(() => refs.volumeSlider.removeEventListener('input', handler));
|
|
449
|
+
// Initial fill
|
|
450
|
+
updateRangeFill(refs.volumeSlider);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if (refs.progressBar) {
|
|
454
|
+
const handler = (e) => {
|
|
455
|
+
if (!audio.duration) return;
|
|
456
|
+
const pct = parseFloat(e.target.value);
|
|
457
|
+
audio.currentTime = (pct / 100) * audio.duration;
|
|
458
|
+
updateRangeFill(refs.progressBar);
|
|
459
|
+
};
|
|
460
|
+
refs.progressBar.addEventListener('input', handler);
|
|
461
|
+
cleanupFunctions.push(() => refs.progressBar.removeEventListener('input', handler));
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Playlist item click (event delegation)
|
|
465
|
+
if (refs.playlistPanel) {
|
|
466
|
+
const panelHandler = (e) => {
|
|
467
|
+
const item = e.target.closest('.vd-music-player-playlist-item');
|
|
468
|
+
if (!item) return;
|
|
469
|
+
const idx = parseInt(item.getAttribute('data-index'), 10);
|
|
470
|
+
if (!isNaN(idx)) loadTrack(idx, true);
|
|
471
|
+
};
|
|
472
|
+
refs.playlistPanel.addEventListener('click', panelHandler);
|
|
473
|
+
cleanupFunctions.push(() =>
|
|
474
|
+
refs.playlistPanel.removeEventListener('click', panelHandler)
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/* ── Initial render ─────────────────────────────────── */
|
|
479
|
+
renderPlayIcon();
|
|
480
|
+
renderTrackName();
|
|
481
|
+
renderVolumeIcon();
|
|
482
|
+
if (opts.showPlaylist) renderPlaylistItems();
|
|
483
|
+
|
|
484
|
+
/* ── Persist instance ───────────────────────────────── */
|
|
485
|
+
this.instances.set(container, { state, audio, refs, cleanup: cleanupFunctions });
|
|
486
|
+
container.setAttribute('data-music-player-initialized', 'true');
|
|
487
|
+
},
|
|
488
|
+
|
|
489
|
+
/* ─── DOM builder ─────────────────────────────────────── */
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Build the inner DOM structure inside container.
|
|
493
|
+
* Pre-existing inner content is replaced only if it has no
|
|
494
|
+
* recognised child elements (allows server-rendered markup).
|
|
495
|
+
* @param {HTMLElement} container
|
|
496
|
+
* @param {Object} state
|
|
497
|
+
*/
|
|
498
|
+
_buildDOM: function (container, state) {
|
|
499
|
+
// Skip if already has the expected structure
|
|
500
|
+
if (container.querySelector('.vd-music-player-controls')) return;
|
|
501
|
+
|
|
502
|
+
container.setAttribute('role', 'region');
|
|
503
|
+
container.setAttribute('aria-label', 'Music Player');
|
|
504
|
+
|
|
505
|
+
if (state.showProgress) container.classList.add('has-progress');
|
|
506
|
+
if (state.showPlaylist) container.classList.add('has-playlist');
|
|
507
|
+
|
|
508
|
+
// Track info row
|
|
509
|
+
const info = document.createElement('div');
|
|
510
|
+
info.className = 'vd-music-player-info';
|
|
511
|
+
|
|
512
|
+
const iconWrap = document.createElement('span');
|
|
513
|
+
iconWrap.className = 'vd-music-player-icon';
|
|
514
|
+
iconWrap.setAttribute('aria-hidden', 'true');
|
|
515
|
+
iconWrap.appendChild(icon('music-note'));
|
|
516
|
+
|
|
517
|
+
const trackName = document.createElement('span');
|
|
518
|
+
trackName.className = 'vd-music-player-track-name';
|
|
519
|
+
trackName.setAttribute('aria-live', 'polite');
|
|
520
|
+
trackName.setAttribute('aria-atomic', 'true');
|
|
521
|
+
|
|
522
|
+
info.appendChild(iconWrap);
|
|
523
|
+
info.appendChild(trackName);
|
|
524
|
+
container.appendChild(info);
|
|
525
|
+
|
|
526
|
+
// Controls row
|
|
527
|
+
const controls = document.createElement('div');
|
|
528
|
+
controls.className = 'vd-music-player-controls';
|
|
529
|
+
controls.setAttribute('role', 'group');
|
|
530
|
+
controls.setAttribute('aria-label', 'Playback controls');
|
|
531
|
+
|
|
532
|
+
const btnPrev = document.createElement('button');
|
|
533
|
+
btnPrev.type = 'button';
|
|
534
|
+
btnPrev.className = 'vd-music-player-btn vd-music-player-btn-prev';
|
|
535
|
+
btnPrev.setAttribute('aria-label', 'Previous track');
|
|
536
|
+
btnPrev.appendChild(icon('skip-back'));
|
|
537
|
+
|
|
538
|
+
const btnPlay = document.createElement('button');
|
|
539
|
+
btnPlay.type = 'button';
|
|
540
|
+
btnPlay.className = 'vd-music-player-btn vd-music-player-btn-play';
|
|
541
|
+
btnPlay.setAttribute('aria-label', 'Play');
|
|
542
|
+
btnPlay.appendChild(icon('play'));
|
|
543
|
+
|
|
544
|
+
const btnNext = document.createElement('button');
|
|
545
|
+
btnNext.type = 'button';
|
|
546
|
+
btnNext.className = 'vd-music-player-btn vd-music-player-btn-next';
|
|
547
|
+
btnNext.setAttribute('aria-label', 'Next track');
|
|
548
|
+
btnNext.appendChild(icon('skip-forward'));
|
|
549
|
+
|
|
550
|
+
controls.appendChild(btnPrev);
|
|
551
|
+
controls.appendChild(btnPlay);
|
|
552
|
+
controls.appendChild(btnNext);
|
|
553
|
+
|
|
554
|
+
// Optional shuffle button
|
|
555
|
+
if (state.showPlaylist || state.shuffle !== undefined) {
|
|
556
|
+
// Always render shuffle so it can be shown when shuffle option is used
|
|
557
|
+
const btnShuffle = document.createElement('button');
|
|
558
|
+
btnShuffle.type = 'button';
|
|
559
|
+
btnShuffle.className = 'vd-music-player-btn vd-music-player-btn-shuffle';
|
|
560
|
+
btnShuffle.setAttribute('aria-label', 'Shuffle');
|
|
561
|
+
btnShuffle.setAttribute('aria-pressed', state.shuffle ? 'true' : 'false');
|
|
562
|
+
btnShuffle.appendChild(icon('shuffle'));
|
|
563
|
+
controls.appendChild(btnShuffle);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// Spacer
|
|
567
|
+
const spacer = document.createElement('span');
|
|
568
|
+
spacer.className = 'vd-music-player-spacer';
|
|
569
|
+
spacer.setAttribute('aria-hidden', 'true');
|
|
570
|
+
controls.appendChild(spacer);
|
|
571
|
+
|
|
572
|
+
// Volume
|
|
573
|
+
const volumeWrap = document.createElement('div');
|
|
574
|
+
volumeWrap.className = 'vd-music-player-volume';
|
|
575
|
+
|
|
576
|
+
const volumeIcon = document.createElement('span');
|
|
577
|
+
volumeIcon.className = 'vd-music-player-volume-icon';
|
|
578
|
+
volumeIcon.setAttribute('aria-hidden', 'true');
|
|
579
|
+
|
|
580
|
+
const volumeSlider = document.createElement('input');
|
|
581
|
+
volumeSlider.type = 'range';
|
|
582
|
+
volumeSlider.className = 'vd-music-player-volume-slider';
|
|
583
|
+
volumeSlider.min = '0';
|
|
584
|
+
volumeSlider.max = '1';
|
|
585
|
+
volumeSlider.step = '0.01';
|
|
586
|
+
volumeSlider.value = String(state.volume);
|
|
587
|
+
volumeSlider.setAttribute('aria-label', 'Volume');
|
|
588
|
+
|
|
589
|
+
volumeWrap.appendChild(volumeIcon);
|
|
590
|
+
volumeWrap.appendChild(volumeSlider);
|
|
591
|
+
controls.appendChild(volumeWrap);
|
|
592
|
+
|
|
593
|
+
// Optional playlist toggle button
|
|
594
|
+
if (state.showPlaylist) {
|
|
595
|
+
const btnPlaylist = document.createElement('button');
|
|
596
|
+
btnPlaylist.type = 'button';
|
|
597
|
+
btnPlaylist.className = 'vd-music-player-btn vd-music-player-btn-playlist';
|
|
598
|
+
btnPlaylist.setAttribute('aria-label', 'Show playlist');
|
|
599
|
+
btnPlaylist.setAttribute('aria-expanded', 'false');
|
|
600
|
+
btnPlaylist.appendChild(icon('playlist'));
|
|
601
|
+
controls.appendChild(btnPlaylist);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
container.appendChild(controls);
|
|
605
|
+
|
|
606
|
+
// Optional progress bar
|
|
607
|
+
if (state.showProgress) {
|
|
608
|
+
const progressRow = document.createElement('div');
|
|
609
|
+
progressRow.className = 'vd-music-player-progress';
|
|
610
|
+
|
|
611
|
+
const timeElapsed = document.createElement('span');
|
|
612
|
+
timeElapsed.className = 'vd-music-player-time vd-music-player-time-elapsed';
|
|
613
|
+
timeElapsed.textContent = '0:00';
|
|
614
|
+
timeElapsed.setAttribute('aria-hidden', 'true');
|
|
615
|
+
|
|
616
|
+
const progressBar = document.createElement('input');
|
|
617
|
+
progressBar.type = 'range';
|
|
618
|
+
progressBar.className = 'vd-music-player-progress-bar';
|
|
619
|
+
progressBar.min = '0';
|
|
620
|
+
progressBar.max = '100';
|
|
621
|
+
progressBar.step = '0.1';
|
|
622
|
+
progressBar.value = '0';
|
|
623
|
+
progressBar.setAttribute('aria-label', 'Seek');
|
|
624
|
+
|
|
625
|
+
const timeDuration = document.createElement('span');
|
|
626
|
+
timeDuration.className = 'vd-music-player-time vd-music-player-time-duration';
|
|
627
|
+
timeDuration.textContent = '0:00';
|
|
628
|
+
timeDuration.setAttribute('aria-hidden', 'true');
|
|
629
|
+
|
|
630
|
+
progressRow.appendChild(timeElapsed);
|
|
631
|
+
progressRow.appendChild(progressBar);
|
|
632
|
+
progressRow.appendChild(timeDuration);
|
|
633
|
+
container.appendChild(progressRow);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// Optional playlist panel (hidden until toggled)
|
|
637
|
+
if (state.showPlaylist) {
|
|
638
|
+
const playlist = document.createElement('div');
|
|
639
|
+
playlist.className = 'vd-music-player-playlist';
|
|
640
|
+
playlist.setAttribute('aria-label', 'Playlist');
|
|
641
|
+
container.appendChild(playlist);
|
|
642
|
+
}
|
|
643
|
+
},
|
|
644
|
+
|
|
645
|
+
/* ─── Public API ──────────────────────────────────────── */
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* @param {HTMLElement} container
|
|
649
|
+
*/
|
|
650
|
+
play: function (container) {
|
|
651
|
+
const inst = this.instances.get(container);
|
|
652
|
+
if (!inst) return;
|
|
653
|
+
if (!inst.audio.src && inst.state.tracks.length) {
|
|
654
|
+
inst.audio.src = inst.state.tracks[inst.state.currentIndex].url;
|
|
655
|
+
}
|
|
656
|
+
inst.audio.play().catch(() => {});
|
|
657
|
+
},
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* @param {HTMLElement} container
|
|
661
|
+
*/
|
|
662
|
+
pause: function (container) {
|
|
663
|
+
const inst = this.instances.get(container);
|
|
664
|
+
if (inst) inst.audio.pause();
|
|
665
|
+
},
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* @param {HTMLElement} container
|
|
669
|
+
*/
|
|
670
|
+
toggle: function (container) {
|
|
671
|
+
const inst = this.instances.get(container);
|
|
672
|
+
if (!inst) return;
|
|
673
|
+
if (inst.state.isPlaying) {
|
|
674
|
+
this.pause(container);
|
|
675
|
+
} else {
|
|
676
|
+
this.play(container);
|
|
677
|
+
}
|
|
678
|
+
},
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* @param {HTMLElement} container
|
|
682
|
+
*/
|
|
683
|
+
next: function (container) {
|
|
684
|
+
const inst = this.instances.get(container);
|
|
685
|
+
if (!inst || !inst.state.tracks.length) return;
|
|
686
|
+
const next = (inst.state.currentIndex + 1) % inst.state.tracks.length;
|
|
687
|
+
this._loadTrack(inst, next, inst.state.isPlaying);
|
|
688
|
+
},
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* @param {HTMLElement} container
|
|
692
|
+
*/
|
|
693
|
+
previous: function (container) {
|
|
694
|
+
const inst = this.instances.get(container);
|
|
695
|
+
if (!inst || !inst.state.tracks.length) return;
|
|
696
|
+
const len = inst.state.tracks.length;
|
|
697
|
+
const prev = (inst.state.currentIndex - 1 + len) % len;
|
|
698
|
+
this._loadTrack(inst, prev, inst.state.isPlaying);
|
|
699
|
+
},
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* @param {HTMLElement} container
|
|
703
|
+
* @param {number} value - 0 to 1
|
|
704
|
+
*/
|
|
705
|
+
setVolume: function (container, value) {
|
|
706
|
+
const inst = this.instances.get(container);
|
|
707
|
+
if (!inst) return;
|
|
708
|
+
const v = Math.max(0, Math.min(1, value));
|
|
709
|
+
inst.state.volume = v;
|
|
710
|
+
inst.audio.volume = v;
|
|
711
|
+
if (inst.refs.volumeSlider) {
|
|
712
|
+
inst.refs.volumeSlider.value = String(v);
|
|
713
|
+
updateRangeFill(inst.refs.volumeSlider);
|
|
714
|
+
}
|
|
715
|
+
container.dispatchEvent(
|
|
716
|
+
new CustomEvent('musicplayer:volumechange', { bubbles: true, detail: { volume: v } })
|
|
717
|
+
);
|
|
718
|
+
},
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* @param {HTMLElement} container
|
|
722
|
+
* @param {number} index - Track index
|
|
723
|
+
*/
|
|
724
|
+
setTrack: function (container, index) {
|
|
725
|
+
const inst = this.instances.get(container);
|
|
726
|
+
if (!inst) return;
|
|
727
|
+
this._loadTrack(inst, index, inst.state.isPlaying);
|
|
728
|
+
},
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* Shuffle or un-shuffle the track list.
|
|
732
|
+
* @param {HTMLElement} container
|
|
733
|
+
*/
|
|
734
|
+
shuffle: function (container) {
|
|
735
|
+
const inst = this.instances.get(container);
|
|
736
|
+
if (!inst || !inst.refs.btnShuffle) return;
|
|
737
|
+
inst.refs.btnShuffle.click();
|
|
738
|
+
},
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* Return a shallow copy of the current player state.
|
|
742
|
+
* @param {HTMLElement} container
|
|
743
|
+
* @returns {Object|null}
|
|
744
|
+
*/
|
|
745
|
+
getState: function (container) {
|
|
746
|
+
const inst = this.instances.get(container);
|
|
747
|
+
if (!inst) return null;
|
|
748
|
+
const s = inst.state;
|
|
749
|
+
return {
|
|
750
|
+
isPlaying: s.isPlaying,
|
|
751
|
+
currentIndex: s.currentIndex,
|
|
752
|
+
currentTrack: s.tracks[s.currentIndex] || null,
|
|
753
|
+
volume: s.volume,
|
|
754
|
+
shuffle: s.shuffle,
|
|
755
|
+
tracks: s.tracks.slice(),
|
|
756
|
+
};
|
|
757
|
+
},
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* Stop playback, clean up listeners, remove instance.
|
|
761
|
+
* @param {HTMLElement} container
|
|
762
|
+
*/
|
|
763
|
+
destroy: function (container) {
|
|
764
|
+
const inst = this.instances.get(container);
|
|
765
|
+
if (!inst) return;
|
|
766
|
+
inst.cleanup.forEach((fn) => fn());
|
|
767
|
+
this.instances.delete(container);
|
|
768
|
+
container.removeAttribute('data-music-player-initialized');
|
|
769
|
+
},
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* Destroy all instances.
|
|
773
|
+
*/
|
|
774
|
+
destroyAll: function () {
|
|
775
|
+
this.instances.forEach((_, container) => this.destroy(container));
|
|
776
|
+
},
|
|
777
|
+
|
|
778
|
+
/* ─── Internal helpers ────────────────────────────────── */
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
* Load track by index on an already-initialised instance object.
|
|
782
|
+
* @param {Object} inst
|
|
783
|
+
* @param {number} index
|
|
784
|
+
* @param {boolean} autoPlay
|
|
785
|
+
*/
|
|
786
|
+
_loadTrack: function (inst, index, autoPlay) {
|
|
787
|
+
const track = inst.state.tracks[index];
|
|
788
|
+
if (!track) return;
|
|
789
|
+
const container = this._containerOf(inst);
|
|
790
|
+
|
|
791
|
+
inst.state.currentIndex = index;
|
|
792
|
+
inst.audio.src = track.url;
|
|
793
|
+
|
|
794
|
+
if (inst.refs.trackName) {
|
|
795
|
+
inst.refs.trackName.textContent = track.name || 'Unknown Track';
|
|
796
|
+
inst.refs.trackName.classList.remove('is-idle');
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// Update playlist highlights
|
|
800
|
+
if (inst.refs.playlistPanel) {
|
|
801
|
+
inst.refs.playlistPanel.querySelectorAll('.vd-music-player-playlist-item').forEach((item, i) => {
|
|
802
|
+
const active = i === index;
|
|
803
|
+
item.classList.toggle('is-active', active);
|
|
804
|
+
item.setAttribute('aria-current', active ? 'true' : 'false');
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// Reset progress
|
|
809
|
+
if (inst.refs.progressBar) {
|
|
810
|
+
inst.refs.progressBar.value = '0';
|
|
811
|
+
updateRangeFill(inst.refs.progressBar);
|
|
812
|
+
}
|
|
813
|
+
if (inst.refs.timeElapsed) inst.refs.timeElapsed.textContent = '0:00';
|
|
814
|
+
if (inst.refs.timeDuration) inst.refs.timeDuration.textContent = '0:00';
|
|
815
|
+
|
|
816
|
+
if (container) {
|
|
817
|
+
container.dispatchEvent(
|
|
818
|
+
new CustomEvent('musicplayer:trackchange', {
|
|
819
|
+
bubbles: true,
|
|
820
|
+
detail: { index, name: track.name, url: track.url },
|
|
821
|
+
})
|
|
822
|
+
);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
if (autoPlay) inst.audio.play().catch(() => {});
|
|
826
|
+
},
|
|
827
|
+
|
|
828
|
+
/**
|
|
829
|
+
* Reverse-lookup the container element for a given instance object.
|
|
830
|
+
* @param {Object} inst
|
|
831
|
+
* @returns {HTMLElement|null}
|
|
832
|
+
*/
|
|
833
|
+
_containerOf: function (inst) {
|
|
834
|
+
for (const [container, i] of this.instances) {
|
|
835
|
+
if (i === inst) return container;
|
|
836
|
+
}
|
|
837
|
+
return null;
|
|
838
|
+
},
|
|
839
|
+
};
|
|
840
|
+
|
|
841
|
+
// Register with Vanduo framework
|
|
842
|
+
if (typeof window.Vanduo !== 'undefined') {
|
|
843
|
+
window.Vanduo.register('musicPlayer', MusicPlayer);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// Convenience global
|
|
847
|
+
window.VanduoMusicPlayer = MusicPlayer;
|
|
848
|
+
})();
|