@vanduo-oss/framework 1.3.1 → 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.
@@ -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
+ })();