@vanduo-oss/framework 1.4.1 → 1.4.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,1294 +0,0 @@
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
- * glass {boolean} - Frosted-glass surface styling (default false)
14
- * detachable {boolean} - Show detach/attach; float above page when detached (default false)
15
- * floatingPosition {string|null} - After detach: 'bottom-left' or 'bottom-right' (default null = bottom-right)
16
- * draggable {boolean} - When detached+detachable, drag by handle (default false)
17
- * minimizable {boolean} - Minimize/expand control (default false)
18
- * startMinimized {boolean} - On first detach, start minimized (default false)
19
- * persistPosition {boolean} - Save floating x/y in localStorage (default false)
20
- * persistKey {string} - Storage key for persist (default from element id)
21
- *
22
- * Custom events (all bubble, dispatched on container):
23
- * musicplayer:play — playback started
24
- * musicplayer:pause — playback paused
25
- * musicplayer:trackchange — detail: { index, name, url }
26
- * musicplayer:volumechange — detail: { volume }
27
- * musicplayer:ended — last track ended (autoAdvance=false only)
28
- * musicplayer:detach — after floating player is created
29
- * musicplayer:attach — after returned to document flow
30
- * musicplayer:minimize — collapsed to minimal controls
31
- * musicplayer:expand — full controls visible again
32
- *
33
- * Programmatic API:
34
- * MusicPlayer.init(container?, options?)
35
- * MusicPlayer.play(container)
36
- * MusicPlayer.pause(container)
37
- * MusicPlayer.toggle(container)
38
- * MusicPlayer.next(container)
39
- * MusicPlayer.previous(container)
40
- * MusicPlayer.setVolume(container, value)
41
- * MusicPlayer.setTrack(container, index)
42
- * MusicPlayer.shuffle(container)
43
- * MusicPlayer.detach(container, position?)
44
- * MusicPlayer.attach(container)
45
- * MusicPlayer.minimize(container)
46
- * MusicPlayer.expand(container)
47
- * MusicPlayer.toggleMinimize(container)
48
- * MusicPlayer.setPosition(container, 'bottom-left'|'bottom-right'|{x,y})
49
- * MusicPlayer.getState(container)
50
- * MusicPlayer.destroy(container)
51
- * MusicPlayer.destroyAll()
52
- */
53
-
54
- (function () {
55
- 'use strict';
56
-
57
- /* ─── Helpers ─────────────────────────────────────────── */
58
-
59
- /**
60
- * Fisher-Yates shuffle (returns new array).
61
- * @param {Array} arr
62
- * @returns {Array}
63
- */
64
- function shuffleArray(arr) {
65
- const shuffled = arr.slice();
66
- for (let i = shuffled.length - 1; i > 0; i--) {
67
- const j = Math.floor(Math.random() * (i + 1));
68
- const tmp = shuffled[i];
69
- shuffled[i] = shuffled[j];
70
- shuffled[j] = tmp;
71
- }
72
- return shuffled;
73
- }
74
-
75
- /**
76
- * Format seconds as m:ss.
77
- * @param {number} seconds
78
- * @returns {string}
79
- */
80
- function formatTime(seconds) {
81
- if (!isFinite(seconds) || seconds < 0) return '0:00';
82
- const m = Math.floor(seconds / 60);
83
- const s = Math.floor(seconds % 60);
84
- return m + ':' + (s < 10 ? '0' : '') + s;
85
- }
86
-
87
- /**
88
- * Set CSS background-size on a range input to visually fill the track.
89
- * @param {HTMLInputElement} input
90
- */
91
- /**
92
- * @param {string|undefined} id
93
- * @returns {string}
94
- */
95
- function persistStorageKey(id) {
96
- return 'vanduo:music-player:' + (id && id.trim() ? id.trim() : 'default') + ':pos';
97
- }
98
-
99
- function updateRangeFill(input) {
100
- const min = parseFloat(input.min) || 0;
101
- const max = parseFloat(input.max) || 1;
102
- const val = parseFloat(input.value) || 0;
103
- const pct = ((val - min) / (max - min)) * 100;
104
- input.style.setProperty('--vd-fill', pct + '%');
105
- // Fallback inline gradient for browsers without ::-moz-range-progress
106
- input.style.backgroundImage =
107
- 'linear-gradient(to right, var(--vd-music-player-track-fill, currentColor) 0%, ' +
108
- 'var(--vd-music-player-track-fill, currentColor) ' + pct + '%, ' +
109
- 'var(--vd-music-player-track-bg, #ccc) ' + pct + '%, ' +
110
- 'var(--vd-music-player-track-bg, #ccc) 100%)';
111
- }
112
-
113
- /* ─── Phosphor icon helper (matches framework icon style) ─ */
114
-
115
- /**
116
- * Return an <i class="ph ph-{name}"> element (Phosphor icon).
117
- * @param {string} name
118
- * @returns {HTMLElement}
119
- */
120
- function icon(name) {
121
- const el = document.createElement('i');
122
- el.className = 'ph ph-' + name;
123
- el.setAttribute('aria-hidden', 'true');
124
- return el;
125
- }
126
-
127
- /* ─── Component ───────────────────────────────────────── */
128
-
129
- const MusicPlayer = {
130
- /** @type {Map<HTMLElement, Object>} */
131
- instances: new Map(),
132
-
133
- /**
134
- * Default options.
135
- */
136
- defaults: {
137
- tracks: [],
138
- volume: 0.5,
139
- shuffle: false,
140
- showProgress: false,
141
- showPlaylist: false,
142
- autoAdvance: true,
143
- glass: false,
144
- detachable: false,
145
- /** @type {null|string} */
146
- floatingPosition: null,
147
- draggable: false,
148
- minimizable: false,
149
- startMinimized: false,
150
- persistPosition: false,
151
- persistKey: '',
152
- },
153
-
154
- /**
155
- * Auto-initialize all .vd-music-player / [data-music-player] elements.
156
- * Options can be provided via data-music-player-options (JSON string).
157
- */
158
- init: function (root) {
159
- window.Vanduo.queryAll(root, '.vd-music-player, [data-music-player]').forEach((el) => {
160
- if (this.instances.has(el)) return;
161
-
162
- let opts = {};
163
- const attr = el.getAttribute('data-music-player-options');
164
- if (attr) {
165
- try { opts = JSON.parse(attr); } catch (_) { /* ignore malformed JSON */ }
166
- }
167
- this.initPlayer(el, opts);
168
- });
169
- },
170
-
171
- /**
172
- * Initialize a single player element.
173
- * @param {HTMLElement} container
174
- * @param {Object} [options]
175
- */
176
- initPlayer: function (container, options) {
177
- const opts = Object.assign({}, this.defaults, options || {});
178
-
179
- // Validate and normalise tracks
180
- const rawTracks = Array.isArray(opts.tracks) ? opts.tracks : [];
181
- const tracks = rawTracks.filter((t) => t && typeof t.url === 'string' && t.url.trim());
182
-
183
- // Build shuffled working copy without mutating opts
184
- const trackList = opts.shuffle ? shuffleArray(tracks) : tracks.slice();
185
-
186
- /* ── State ─────────────────────────────────────────── */
187
- const state = {
188
- tracks: trackList,
189
- originalTracks: tracks.slice(),
190
- currentIndex: 0,
191
- isPlaying: false,
192
- volume: Math.max(0, Math.min(1, opts.volume)),
193
- shuffle: opts.shuffle,
194
- showProgress: opts.showProgress,
195
- showPlaylist: opts.showPlaylist,
196
- autoAdvance: opts.autoAdvance,
197
- audio: null,
198
- glass: Boolean(opts.glass),
199
- detachable: Boolean(opts.detachable),
200
- floatingPosition: opts.floatingPosition || 'bottom-right',
201
- draggable: Boolean(opts.draggable) && Boolean(opts.detachable),
202
- minimizable: Boolean(opts.minimizable),
203
- startMinimized: Boolean(opts.startMinimized),
204
- persistPosition: Boolean(opts.persistPosition),
205
- persistKey: typeof opts.persistKey === 'string' ? opts.persistKey : '',
206
- isDetached: false,
207
- isMinimized: false,
208
- _startMinimizeApplied: false,
209
- };
210
-
211
- /* ── Audio element ─────────────────────────────────── */
212
- const audio = new Audio();
213
- audio.volume = state.volume;
214
- audio.preload = 'metadata';
215
- state.audio = audio;
216
-
217
- /* ── Build DOM ─────────────────────────────────────── */
218
- this._buildDOM(container, state);
219
-
220
- // Grab references after DOM build
221
- const refs = {
222
- btnPlay: container.querySelector('.vd-music-player-btn-play'),
223
- btnPrev: container.querySelector('.vd-music-player-btn-prev'),
224
- btnNext: container.querySelector('.vd-music-player-btn-next'),
225
- btnShuffle: container.querySelector('.vd-music-player-btn-shuffle'),
226
- btnPlaylist: container.querySelector('.vd-music-player-btn-playlist'),
227
- btnDetach: container.querySelector('.vd-music-player-btn-detach'),
228
- btnAttach: container.querySelector('.vd-music-player-btn-attach'),
229
- btnMinimize: container.querySelector('.vd-music-player-btn-minimize'),
230
- dragHandle: container.querySelector('.vd-music-player-drag-handle'),
231
- trackName: container.querySelector('.vd-music-player-track-name'),
232
- volumeSlider: container.querySelector('.vd-music-player-volume-slider'),
233
- volumeIcon: container.querySelector('.vd-music-player-volume-icon'),
234
- progressBar: container.querySelector('.vd-music-player-progress-bar'),
235
- timeElapsed: container.querySelector('.vd-music-player-time-elapsed'),
236
- timeDuration: container.querySelector('.vd-music-player-time-duration'),
237
- playlistPanel: container.querySelector('.vd-music-player-playlist'),
238
- };
239
-
240
- /* ── Internal render helpers ───────────────────────── */
241
-
242
- const renderPlayIcon = () => {
243
- const btn = refs.btnPlay;
244
- if (!btn) return;
245
- btn.innerHTML = '';
246
- btn.appendChild(icon(state.isPlaying ? 'pause' : 'play'));
247
- btn.setAttribute('aria-label', state.isPlaying ? 'Pause' : 'Play');
248
- btn.classList.toggle('is-active', state.isPlaying);
249
- };
250
-
251
- const renderTrackName = () => {
252
- const el = refs.trackName;
253
- if (!el) return;
254
- const track = state.tracks[state.currentIndex];
255
- if (track) {
256
- el.textContent = track.name || 'Unknown Track';
257
- el.classList.remove('is-idle');
258
- } else {
259
- el.textContent = 'No tracks loaded';
260
- el.classList.add('is-idle');
261
- }
262
- };
263
-
264
- const renderVolumeIcon = () => {
265
- const el = refs.volumeIcon;
266
- if (!el) return;
267
- el.innerHTML = '';
268
- const v = state.volume;
269
- const name = v === 0 ? 'speaker-none' : v < 0.5 ? 'speaker-low' : 'speaker-high';
270
- el.appendChild(icon(name));
271
- };
272
-
273
- const renderShuffleBtn = () => {
274
- const btn = refs.btnShuffle;
275
- if (!btn) return;
276
- btn.classList.toggle('is-active', state.shuffle);
277
- btn.setAttribute('aria-pressed', state.shuffle ? 'true' : 'false');
278
- };
279
-
280
- const renderPlaylistItems = () => {
281
- const panel = refs.playlistPanel;
282
- if (!panel) return;
283
- panel.innerHTML = '';
284
- state.tracks.forEach((track, i) => {
285
- const item = document.createElement('button');
286
- item.className =
287
- 'vd-music-player-playlist-item' + (i === state.currentIndex ? ' is-active' : '');
288
- item.type = 'button';
289
- item.setAttribute('data-index', String(i));
290
- item.setAttribute('aria-current', i === state.currentIndex ? 'true' : 'false');
291
-
292
- const num = document.createElement('span');
293
- num.className = 'vd-music-player-playlist-num';
294
- num.textContent = String(i + 1);
295
-
296
- const name = document.createElement('span');
297
- name.className = 'vd-music-player-playlist-name';
298
- name.textContent = track.name || 'Track ' + (i + 1);
299
-
300
- item.appendChild(num);
301
- item.appendChild(name);
302
- panel.appendChild(item);
303
- });
304
- };
305
-
306
- const renderProgress = () => {
307
- const bar = refs.progressBar;
308
- if (!bar || !audio.duration) return;
309
- const pct = (audio.currentTime / audio.duration) * 100;
310
- bar.value = String(pct);
311
- updateRangeFill(bar);
312
- if (refs.timeElapsed) refs.timeElapsed.textContent = formatTime(audio.currentTime);
313
- if (refs.timeDuration) refs.timeDuration.textContent = formatTime(audio.duration);
314
- };
315
-
316
- /* ── Load track ─────────────────────────────────────── */
317
-
318
- const loadTrack = (index, autoPlay) => {
319
- const track = state.tracks[index];
320
- if (!track) return;
321
- state.currentIndex = index;
322
- audio.src = track.url;
323
- renderTrackName();
324
- renderPlaylistItems();
325
-
326
- // Reset progress
327
- if (refs.progressBar) {
328
- refs.progressBar.value = '0';
329
- updateRangeFill(refs.progressBar);
330
- }
331
- if (refs.timeElapsed) refs.timeElapsed.textContent = '0:00';
332
- if (refs.timeDuration) refs.timeDuration.textContent = '0:00';
333
-
334
- container.dispatchEvent(
335
- new CustomEvent('musicplayer:trackchange', {
336
- bubbles: true,
337
- detail: { index, name: track.name, url: track.url },
338
- })
339
- );
340
-
341
- if (autoPlay) {
342
- audio.play().catch(() => { /* browser may block autoplay */ });
343
- }
344
- };
345
-
346
- /* ── Audio event listeners ─────────────────────────── */
347
- const cleanupFunctions = [];
348
-
349
- const onPlay = () => {
350
- state.isPlaying = true;
351
- renderPlayIcon();
352
- container.dispatchEvent(new CustomEvent('musicplayer:play', { bubbles: true }));
353
- };
354
-
355
- const onPause = () => {
356
- state.isPlaying = false;
357
- renderPlayIcon();
358
- container.dispatchEvent(new CustomEvent('musicplayer:pause', { bubbles: true }));
359
- };
360
-
361
- const onEnded = () => {
362
- if (state.autoAdvance && state.tracks.length > 1) {
363
- const next = (state.currentIndex + 1) % state.tracks.length;
364
- loadTrack(next, true);
365
- } else {
366
- state.isPlaying = false;
367
- renderPlayIcon();
368
- container.dispatchEvent(new CustomEvent('musicplayer:ended', { bubbles: true }));
369
- }
370
- };
371
-
372
- const onTimeUpdate = () => {
373
- if (state.showProgress) renderProgress();
374
- };
375
-
376
- const onLoadedMetadata = () => {
377
- if (refs.timeDuration) refs.timeDuration.textContent = formatTime(audio.duration);
378
- if (refs.progressBar) {
379
- refs.progressBar.max = '100';
380
- updateRangeFill(refs.progressBar);
381
- }
382
- };
383
-
384
- audio.addEventListener('play', onPlay);
385
- audio.addEventListener('pause', onPause);
386
- audio.addEventListener('ended', onEnded);
387
- audio.addEventListener('timeupdate', onTimeUpdate);
388
- audio.addEventListener('loadedmetadata', onLoadedMetadata);
389
- cleanupFunctions.push(() => {
390
- audio.removeEventListener('play', onPlay);
391
- audio.removeEventListener('pause', onPause);
392
- audio.removeEventListener('ended', onEnded);
393
- audio.removeEventListener('timeupdate', onTimeUpdate);
394
- audio.removeEventListener('loadedmetadata', onLoadedMetadata);
395
- audio.pause();
396
- audio.src = '';
397
- });
398
-
399
- /* ── Control button listeners ──────────────────────── */
400
-
401
- if (refs.btnPlay) {
402
- const handler = () => {
403
- if (!audio.src && state.tracks.length) loadTrack(state.currentIndex, false);
404
- if (state.isPlaying) {
405
- audio.pause();
406
- } else {
407
- audio.play().catch(() => {});
408
- }
409
- };
410
- refs.btnPlay.addEventListener('click', handler);
411
- cleanupFunctions.push(() => refs.btnPlay.removeEventListener('click', handler));
412
-
413
- // Keyboard: Space / Enter (already native for <button>; guard for edge cases)
414
- const keyHandler = (e) => {
415
- if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); handler(); }
416
- };
417
- refs.btnPlay.addEventListener('keydown', keyHandler);
418
- cleanupFunctions.push(() => refs.btnPlay.removeEventListener('keydown', keyHandler));
419
- }
420
-
421
- if (refs.btnPrev) {
422
- const handler = () => {
423
- if (!state.tracks.length) return;
424
- // If more than 3s into track, restart; otherwise go to previous
425
- if (audio.currentTime > 3) {
426
- audio.currentTime = 0;
427
- } else {
428
- const prev =
429
- state.currentIndex === 0 ? state.tracks.length - 1 : state.currentIndex - 1;
430
- loadTrack(prev, state.isPlaying);
431
- }
432
- };
433
- refs.btnPrev.addEventListener('click', handler);
434
- cleanupFunctions.push(() => refs.btnPrev.removeEventListener('click', handler));
435
- }
436
-
437
- if (refs.btnNext) {
438
- const handler = () => {
439
- if (!state.tracks.length) return;
440
- const next = (state.currentIndex + 1) % state.tracks.length;
441
- loadTrack(next, state.isPlaying);
442
- };
443
- refs.btnNext.addEventListener('click', handler);
444
- cleanupFunctions.push(() => refs.btnNext.removeEventListener('click', handler));
445
- }
446
-
447
- if (refs.btnShuffle) {
448
- const handler = () => {
449
- state.shuffle = !state.shuffle;
450
- if (state.shuffle) {
451
- const current = state.tracks[state.currentIndex];
452
- state.tracks = shuffleArray(state.tracks);
453
- // Keep current track at position 0 of the new order
454
- const newIdx = state.tracks.findIndex((t) => t === current);
455
- if (newIdx > 0) {
456
- state.tracks.splice(newIdx, 1);
457
- state.tracks.unshift(current);
458
- }
459
- state.currentIndex = 0;
460
- } else {
461
- // Restore original order, keep same track
462
- const current = state.tracks[state.currentIndex];
463
- state.tracks = state.originalTracks.slice();
464
- state.currentIndex = state.tracks.findIndex((t) => t === current);
465
- if (state.currentIndex < 0) state.currentIndex = 0;
466
- }
467
- renderShuffleBtn();
468
- renderPlaylistItems();
469
- };
470
- refs.btnShuffle.addEventListener('click', handler);
471
- cleanupFunctions.push(() => refs.btnShuffle.removeEventListener('click', handler));
472
- }
473
-
474
- if (refs.btnPlaylist) {
475
- const handler = () => {
476
- const panel = refs.playlistPanel;
477
- if (!panel) return;
478
- const isOpen = panel.classList.toggle('is-open');
479
- refs.btnPlaylist.classList.toggle('is-active', isOpen);
480
- refs.btnPlaylist.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
481
- };
482
- refs.btnPlaylist.addEventListener('click', handler);
483
- cleanupFunctions.push(() => refs.btnPlaylist.removeEventListener('click', handler));
484
- }
485
-
486
- if (refs.volumeSlider) {
487
- const handler = (e) => {
488
- const v = parseFloat(e.target.value);
489
- state.volume = v;
490
- audio.volume = v;
491
- renderVolumeIcon();
492
- updateRangeFill(refs.volumeSlider);
493
- container.dispatchEvent(
494
- new CustomEvent('musicplayer:volumechange', { bubbles: true, detail: { volume: v } })
495
- );
496
- };
497
- refs.volumeSlider.addEventListener('input', handler);
498
- cleanupFunctions.push(() => refs.volumeSlider.removeEventListener('input', handler));
499
- // Initial fill
500
- updateRangeFill(refs.volumeSlider);
501
- }
502
-
503
- if (refs.progressBar) {
504
- const handler = (e) => {
505
- if (!audio.duration) return;
506
- const pct = parseFloat(e.target.value);
507
- audio.currentTime = (pct / 100) * audio.duration;
508
- updateRangeFill(refs.progressBar);
509
- };
510
- refs.progressBar.addEventListener('input', handler);
511
- cleanupFunctions.push(() => refs.progressBar.removeEventListener('input', handler));
512
- }
513
-
514
- // Playlist item click (event delegation)
515
- if (refs.playlistPanel) {
516
- const panelHandler = (e) => {
517
- const item = e.target.closest('.vd-music-player-playlist-item');
518
- if (!item) return;
519
- const idx = parseInt(item.getAttribute('data-index'), 10);
520
- if (!isNaN(idx)) loadTrack(idx, true);
521
- };
522
- refs.playlistPanel.addEventListener('click', panelHandler);
523
- cleanupFunctions.push(() =>
524
- refs.playlistPanel.removeEventListener('click', panelHandler)
525
- );
526
- }
527
-
528
- /* ── Detach / attach / minimize chrome ─────────────── */
529
- if (refs.btnDetach) {
530
- const h = () => {
531
- this.detach(container);
532
- };
533
- refs.btnDetach.addEventListener('click', h);
534
- cleanupFunctions.push(() => refs.btnDetach.removeEventListener('click', h));
535
- }
536
- if (refs.btnAttach) {
537
- const h = () => {
538
- this.attach(container);
539
- };
540
- refs.btnAttach.addEventListener('click', h);
541
- cleanupFunctions.push(() => refs.btnAttach.removeEventListener('click', h));
542
- }
543
- if (refs.btnMinimize) {
544
- const h = () => {
545
- this.toggleMinimize(container);
546
- };
547
- refs.btnMinimize.addEventListener('click', h);
548
- cleanupFunctions.push(() => refs.btnMinimize.removeEventListener('click', h));
549
- }
550
-
551
- /* ── Initial render ─────────────────────────────────── */
552
- renderPlayIcon();
553
- renderTrackName();
554
- renderVolumeIcon();
555
- if (opts.showPlaylist) renderPlaylistItems();
556
-
557
- /* ── Persist instance ───────────────────────────────── */
558
- this.instances.set(container, {
559
- state,
560
- audio,
561
- refs,
562
- cleanup: cleanupFunctions,
563
- ui: { restore: null, unbindDrag: null },
564
- });
565
- container.setAttribute('data-music-player-initialized', 'true');
566
- },
567
-
568
- /* ─── DOM builder ─────────────────────────────────────── */
569
-
570
- /**
571
- * Build the inner DOM structure inside container.
572
- * Pre-existing inner content is replaced only if it has no
573
- * recognised child elements (allows server-rendered markup).
574
- * @param {HTMLElement} container
575
- * @param {Object} state
576
- */
577
- _buildDOM: function (container, state) {
578
- // Skip if already has the expected structure
579
- if (container.querySelector('.vd-music-player-controls')) return;
580
-
581
- container.setAttribute('role', 'region');
582
- container.setAttribute('aria-label', 'Music Player');
583
-
584
- if (state.showProgress) container.classList.add('has-progress');
585
- if (state.showPlaylist) container.classList.add('has-playlist');
586
- if (state.glass) container.classList.add('vd-music-player-glass');
587
- if (state.draggable) container.classList.add('vd-music-player-draggable');
588
-
589
- // Detach / minimize / drag handle
590
- if (state.detachable || state.minimizable) {
591
- const tb = document.createElement('div');
592
- tb.className = 'vd-music-player-toolbar';
593
- tb.setAttribute('role', 'toolbar');
594
- tb.setAttribute('aria-label', 'Player window');
595
- if (state.draggable) {
596
- const h = document.createElement('button');
597
- h.type = 'button';
598
- h.className = 'vd-music-player-drag-handle';
599
- h.setAttribute('aria-label', 'Drag to move player');
600
- h.appendChild(icon('dots-six-vertical'));
601
- tb.appendChild(h);
602
- }
603
- const tSp = document.createElement('span');
604
- tSp.className = 'vd-music-player-toolbar-spacer';
605
- tSp.setAttribute('aria-hidden', 'true');
606
- tb.appendChild(tSp);
607
- if (state.minimizable) {
608
- const bMin = document.createElement('button');
609
- bMin.type = 'button';
610
- bMin.className = 'vd-music-player-btn vd-music-player-btn-minimize';
611
- bMin.setAttribute('aria-label', 'Minimize player');
612
- bMin.setAttribute('aria-expanded', 'true');
613
- bMin.appendChild(icon('minus'));
614
- tb.appendChild(bMin);
615
- }
616
- if (state.detachable) {
617
- const bOut = document.createElement('button');
618
- bOut.type = 'button';
619
- bOut.className = 'vd-music-player-btn vd-music-player-btn-detach';
620
- bOut.setAttribute('aria-label', 'Detach player');
621
- bOut.appendChild(icon('arrows-out'));
622
- tb.appendChild(bOut);
623
- const bIn = document.createElement('button');
624
- bIn.type = 'button';
625
- bIn.className = 'vd-music-player-btn vd-music-player-btn-attach';
626
- bIn.setAttribute('aria-label', 'Attach player');
627
- bIn.appendChild(icon('arrows-in'));
628
- tb.appendChild(bIn);
629
- }
630
- container.classList.add('vd-music-player-has-chrome');
631
- container.appendChild(tb);
632
- }
633
-
634
- // Track info row
635
- const info = document.createElement('div');
636
- info.className = 'vd-music-player-info';
637
-
638
- const iconWrap = document.createElement('span');
639
- iconWrap.className = 'vd-music-player-icon';
640
- iconWrap.setAttribute('aria-hidden', 'true');
641
- iconWrap.appendChild(icon('music-note'));
642
-
643
- const trackName = document.createElement('span');
644
- trackName.className = 'vd-music-player-track-name';
645
- trackName.setAttribute('aria-live', 'polite');
646
- trackName.setAttribute('aria-atomic', 'true');
647
-
648
- info.appendChild(iconWrap);
649
- info.appendChild(trackName);
650
- container.appendChild(info);
651
-
652
- // Controls row
653
- const controls = document.createElement('div');
654
- controls.className = 'vd-music-player-controls';
655
- controls.setAttribute('role', 'group');
656
- controls.setAttribute('aria-label', 'Playback controls');
657
-
658
- const btnPrev = document.createElement('button');
659
- btnPrev.type = 'button';
660
- btnPrev.className = 'vd-music-player-btn vd-music-player-btn-prev';
661
- btnPrev.setAttribute('aria-label', 'Previous track');
662
- btnPrev.appendChild(icon('skip-back'));
663
-
664
- const btnPlay = document.createElement('button');
665
- btnPlay.type = 'button';
666
- btnPlay.className = 'vd-music-player-btn vd-music-player-btn-play';
667
- btnPlay.setAttribute('aria-label', 'Play');
668
- btnPlay.appendChild(icon('play'));
669
-
670
- const btnNext = document.createElement('button');
671
- btnNext.type = 'button';
672
- btnNext.className = 'vd-music-player-btn vd-music-player-btn-next';
673
- btnNext.setAttribute('aria-label', 'Next track');
674
- btnNext.appendChild(icon('skip-forward'));
675
-
676
- controls.appendChild(btnPrev);
677
- controls.appendChild(btnPlay);
678
- controls.appendChild(btnNext);
679
-
680
- // Optional shuffle button
681
- if (state.showPlaylist || state.shuffle !== undefined) {
682
- // Always render shuffle so it can be shown when shuffle option is used
683
- const btnShuffle = document.createElement('button');
684
- btnShuffle.type = 'button';
685
- btnShuffle.className = 'vd-music-player-btn vd-music-player-btn-shuffle';
686
- btnShuffle.setAttribute('aria-label', 'Shuffle');
687
- btnShuffle.setAttribute('aria-pressed', state.shuffle ? 'true' : 'false');
688
- btnShuffle.appendChild(icon('shuffle'));
689
- controls.appendChild(btnShuffle);
690
- }
691
-
692
- // Spacer
693
- const spacer = document.createElement('span');
694
- spacer.className = 'vd-music-player-spacer';
695
- spacer.setAttribute('aria-hidden', 'true');
696
- controls.appendChild(spacer);
697
-
698
- // Volume
699
- const volumeWrap = document.createElement('div');
700
- volumeWrap.className = 'vd-music-player-volume';
701
-
702
- const volumeIcon = document.createElement('span');
703
- volumeIcon.className = 'vd-music-player-volume-icon';
704
- volumeIcon.setAttribute('aria-hidden', 'true');
705
-
706
- const volumeSlider = document.createElement('input');
707
- volumeSlider.type = 'range';
708
- volumeSlider.className = 'vd-music-player-volume-slider';
709
- volumeSlider.min = '0';
710
- volumeSlider.max = '1';
711
- volumeSlider.step = '0.01';
712
- volumeSlider.value = String(state.volume);
713
- volumeSlider.setAttribute('aria-label', 'Volume');
714
-
715
- volumeWrap.appendChild(volumeIcon);
716
- volumeWrap.appendChild(volumeSlider);
717
- controls.appendChild(volumeWrap);
718
-
719
- // Optional playlist toggle button
720
- if (state.showPlaylist) {
721
- const btnPlaylist = document.createElement('button');
722
- btnPlaylist.type = 'button';
723
- btnPlaylist.className = 'vd-music-player-btn vd-music-player-btn-playlist';
724
- btnPlaylist.setAttribute('aria-label', 'Show playlist');
725
- btnPlaylist.setAttribute('aria-expanded', 'false');
726
- btnPlaylist.appendChild(icon('playlist'));
727
- controls.appendChild(btnPlaylist);
728
- }
729
-
730
- container.appendChild(controls);
731
-
732
- // Optional progress bar
733
- if (state.showProgress) {
734
- const progressRow = document.createElement('div');
735
- progressRow.className = 'vd-music-player-progress';
736
-
737
- const timeElapsed = document.createElement('span');
738
- timeElapsed.className = 'vd-music-player-time vd-music-player-time-elapsed';
739
- timeElapsed.textContent = '0:00';
740
- timeElapsed.setAttribute('aria-hidden', 'true');
741
-
742
- const progressBar = document.createElement('input');
743
- progressBar.type = 'range';
744
- progressBar.className = 'vd-music-player-progress-bar';
745
- progressBar.min = '0';
746
- progressBar.max = '100';
747
- progressBar.step = '0.1';
748
- progressBar.value = '0';
749
- progressBar.setAttribute('aria-label', 'Seek');
750
-
751
- const timeDuration = document.createElement('span');
752
- timeDuration.className = 'vd-music-player-time vd-music-player-time-duration';
753
- timeDuration.textContent = '0:00';
754
- timeDuration.setAttribute('aria-hidden', 'true');
755
-
756
- progressRow.appendChild(timeElapsed);
757
- progressRow.appendChild(progressBar);
758
- progressRow.appendChild(timeDuration);
759
- container.appendChild(progressRow);
760
- }
761
-
762
- // Optional playlist panel (hidden until toggled)
763
- if (state.showPlaylist) {
764
- const playlist = document.createElement('div');
765
- playlist.className = 'vd-music-player-playlist';
766
- playlist.setAttribute('aria-label', 'Playlist');
767
- container.appendChild(playlist);
768
- }
769
- },
770
-
771
- /* ─── Public API ──────────────────────────────────────── */
772
-
773
- /**
774
- * @param {HTMLElement} container
775
- */
776
- play: function (container) {
777
- const inst = this.instances.get(container);
778
- if (!inst) return;
779
- if (!inst.audio.src && inst.state.tracks.length) {
780
- inst.audio.src = inst.state.tracks[inst.state.currentIndex].url;
781
- }
782
- inst.audio.play().catch(() => {});
783
- },
784
-
785
- /**
786
- * @param {HTMLElement} container
787
- */
788
- pause: function (container) {
789
- const inst = this.instances.get(container);
790
- if (inst) inst.audio.pause();
791
- },
792
-
793
- /**
794
- * @param {HTMLElement} container
795
- */
796
- toggle: function (container) {
797
- const inst = this.instances.get(container);
798
- if (!inst) return;
799
- if (inst.state.isPlaying) {
800
- this.pause(container);
801
- } else {
802
- this.play(container);
803
- }
804
- },
805
-
806
- /**
807
- * @param {HTMLElement} container
808
- */
809
- next: function (container) {
810
- const inst = this.instances.get(container);
811
- if (!inst || !inst.state.tracks.length) return;
812
- const next = (inst.state.currentIndex + 1) % inst.state.tracks.length;
813
- this._loadTrack(inst, next, inst.state.isPlaying);
814
- },
815
-
816
- /**
817
- * @param {HTMLElement} container
818
- */
819
- previous: function (container) {
820
- const inst = this.instances.get(container);
821
- if (!inst || !inst.state.tracks.length) return;
822
- const len = inst.state.tracks.length;
823
- const prev = (inst.state.currentIndex - 1 + len) % len;
824
- this._loadTrack(inst, prev, inst.state.isPlaying);
825
- },
826
-
827
- /**
828
- * @param {HTMLElement} container
829
- * @param {number} value - 0 to 1
830
- */
831
- setVolume: function (container, value) {
832
- const inst = this.instances.get(container);
833
- if (!inst) return;
834
- const v = Math.max(0, Math.min(1, value));
835
- inst.state.volume = v;
836
- inst.audio.volume = v;
837
- if (inst.refs.volumeSlider) {
838
- inst.refs.volumeSlider.value = String(v);
839
- updateRangeFill(inst.refs.volumeSlider);
840
- }
841
- container.dispatchEvent(
842
- new CustomEvent('musicplayer:volumechange', { bubbles: true, detail: { volume: v } })
843
- );
844
- },
845
-
846
- /**
847
- * @param {HTMLElement} container
848
- * @param {number} index - Track index
849
- */
850
- setTrack: function (container, index) {
851
- const inst = this.instances.get(container);
852
- if (!inst) return;
853
- this._loadTrack(inst, index, inst.state.isPlaying);
854
- },
855
-
856
- /**
857
- * Shuffle or un-shuffle the track list.
858
- * @param {HTMLElement} container
859
- */
860
- shuffle: function (container) {
861
- const inst = this.instances.get(container);
862
- if (!inst || !inst.refs.btnShuffle) return;
863
- inst.refs.btnShuffle.click();
864
- },
865
-
866
- /**
867
- * Float the player above the page. Requires { detachable: true } at init.
868
- * @param {HTMLElement} container
869
- * @param {string} [position] 'bottom-left' or 'bottom-right'
870
- */
871
- detach: function (container, position) {
872
- const inst = this.instances.get(container);
873
- if (!inst || !inst.state.detachable || inst.state.isDetached) return;
874
- const s = inst.state;
875
- inst.ui = inst.ui || { restore: null, unbindDrag: null };
876
- s.isDetached = true;
877
- inst.ui.restore = {
878
- parent: container.parentNode,
879
- next: container.nextSibling,
880
- };
881
- document.body.appendChild(container);
882
- container.classList.add('vd-music-player-floating', 'vd-music-player-detached');
883
- const pos = position != null && position !== undefined ? position : s.floatingPosition;
884
- this._setCornerPosition(
885
- container,
886
- pos === 'bottom-left' || pos === 'bottom-right' ? pos : 'bottom-right'
887
- );
888
- this._loadPersistedPosition(container, inst);
889
- if (s.startMinimized && !s._startMinimizeApplied) {
890
- s._startMinimizeApplied = true;
891
- this.minimize(container);
892
- }
893
- this._bindFloatingDrag(inst);
894
- container.dispatchEvent(new CustomEvent('musicplayer:detach', { bubbles: true }));
895
- },
896
-
897
- /**
898
- * Return a detached player to its original place in the document.
899
- * @param {HTMLElement} container
900
- */
901
- attach: function (container) {
902
- const inst = this.instances.get(container);
903
- if (!inst || !inst.state.isDetached) return;
904
- this._unbindFloatingDrag(inst);
905
- inst.state.isDetached = false;
906
- const r = inst.ui && inst.ui.restore;
907
- container.classList.remove(
908
- 'vd-music-player-floating',
909
- 'vd-music-player-detached',
910
- 'vd-music-player-floating-bottom-left',
911
- 'vd-music-player-floating-bottom-right',
912
- 'is-position-custom'
913
- );
914
- container.style.removeProperty('--vd-music-player-floating-top');
915
- container.style.removeProperty('--vd-music-player-floating-left');
916
- if (r && r.parent && r.parent.isConnected) {
917
- r.parent.insertBefore(container, r.next);
918
- }
919
- if (inst.ui) {
920
- inst.ui.restore = null;
921
- inst.ui.unbindDrag = null;
922
- }
923
- container.dispatchEvent(new CustomEvent('musicplayer:attach', { bubbles: true }));
924
- },
925
-
926
- /**
927
- * Collapse to essential controls. Requires { minimizable: true } at init.
928
- * @param {HTMLElement} container
929
- */
930
- minimize: function (container) {
931
- const inst = this.instances.get(container);
932
- if (!inst || !inst.state.minimizable || inst.state.isMinimized) return;
933
- const s = inst.state;
934
- s.isMinimized = true;
935
- container.classList.add('vd-music-player-minimized');
936
- this._setMinimizeButtonState(inst, true);
937
- if (
938
- inst.refs.playlistPanel &&
939
- inst.refs.playlistPanel.classList.contains('is-open') &&
940
- inst.refs.btnPlaylist
941
- ) {
942
- inst.refs.playlistPanel.classList.remove('is-open');
943
- inst.refs.btnPlaylist.classList.remove('is-active');
944
- inst.refs.btnPlaylist.setAttribute('aria-expanded', 'false');
945
- }
946
- container.dispatchEvent(new CustomEvent('musicplayer:minimize', { bubbles: true }));
947
- },
948
-
949
- /**
950
- * Restore from minimized state.
951
- * @param {HTMLElement} container
952
- */
953
- expand: function (container) {
954
- const inst = this.instances.get(container);
955
- if (!inst || !inst.state.minimizable || !inst.state.isMinimized) return;
956
- inst.state.isMinimized = false;
957
- container.classList.remove('vd-music-player-minimized');
958
- this._setMinimizeButtonState(inst, false);
959
- container.dispatchEvent(new CustomEvent('musicplayer:expand', { bubbles: true }));
960
- },
961
-
962
- /**
963
- * Toggle minimize / expand.
964
- * @param {HTMLElement} container
965
- */
966
- toggleMinimize: function (container) {
967
- const inst = this.instances.get(container);
968
- if (!inst || !inst.state.minimizable) return;
969
- if (inst.state.isMinimized) {
970
- this.expand(container);
971
- } else {
972
- this.minimize(container);
973
- }
974
- },
975
-
976
- /**
977
- * Set floating corner or pixel position (detached only).
978
- * @param {HTMLElement} container
979
- * @param {string|{x:number,y:number}} position 'bottom-left' | 'bottom-right' | { x, y } viewport
980
- */
981
- setPosition: function (container, position) {
982
- const inst = this.instances.get(container);
983
- if (!inst || !inst.state.isDetached) return;
984
- if (typeof position === 'string') {
985
- this._setCornerPosition(
986
- container,
987
- position === 'bottom-left' || position === 'bottom-right' ? position : 'bottom-right'
988
- );
989
- } else if (position && typeof position.x === 'number' && typeof position.y === 'number') {
990
- this._setCustomPositionFromRect(container, position.x, position.y);
991
- }
992
- if (inst.state.persistPosition) {
993
- const r = container.getBoundingClientRect();
994
- this._savePositionPixels(inst, r.left, r.top);
995
- }
996
- },
997
-
998
- /**
999
- * @param {Object} inst
1000
- * @param {boolean} minimized
1001
- */
1002
- _setMinimizeButtonState: function (inst, minimized) {
1003
- const b = inst.refs && inst.refs.btnMinimize;
1004
- if (!b) return;
1005
- b.innerHTML = '';
1006
- b.appendChild(icon(minimized ? 'plus' : 'minus'));
1007
- b.setAttribute('aria-label', minimized ? 'Expand player' : 'Minimize player');
1008
- b.setAttribute('aria-expanded', minimized ? 'false' : 'true');
1009
- },
1010
-
1011
- /**
1012
- * @param {HTMLElement} container
1013
- * @param {string} which 'bottom-left' | 'bottom-right'
1014
- */
1015
- _setCornerPosition: function (container, which) {
1016
- container.classList.remove('is-position-custom', 'vd-music-player-floating-bottom-left', 'vd-music-player-floating-bottom-right');
1017
- container.style.removeProperty('--vd-music-player-floating-top');
1018
- container.style.removeProperty('--vd-music-player-floating-left');
1019
- if (which === 'bottom-left') {
1020
- container.classList.add('vd-music-player-floating-bottom-left');
1021
- } else {
1022
- container.classList.add('vd-music-player-floating-bottom-right');
1023
- }
1024
- },
1025
-
1026
- /**
1027
- * @param {HTMLElement} container
1028
- * @param {number} left
1029
- * @param {number} top
1030
- */
1031
- _setCustomPositionFromRect: function (container, left, top) {
1032
- container.classList.remove('vd-music-player-floating-bottom-left', 'vd-music-player-floating-bottom-right');
1033
- container.classList.add('is-position-custom');
1034
- container.style.setProperty('--vd-music-player-floating-left', left + 'px');
1035
- container.style.setProperty('--vd-music-player-floating-top', top + 'px');
1036
- },
1037
-
1038
- /**
1039
- * @param {HTMLElement} container
1040
- * @param {Object} inst
1041
- */
1042
- _loadPersistedPosition: function (container, inst) {
1043
- if (!inst.state.persistPosition) return;
1044
- const key = this._persistKeyForInstance(inst, container);
1045
- let raw = null;
1046
- if (typeof window.safeStorageGet === 'function') {
1047
- raw = window.safeStorageGet(key, null);
1048
- } else {
1049
- try {
1050
- raw = localStorage.getItem(key);
1051
- } catch (_e) {
1052
- /* ignore unavailable storage */
1053
- }
1054
- }
1055
- if (!raw) return;
1056
- try {
1057
- const o = JSON.parse(raw);
1058
- if (o && typeof o.x === 'number' && typeof o.y === 'number') {
1059
- this._setCustomPositionFromRect(container, o.x, o.y);
1060
- }
1061
- } catch (_err) { /* ignore */ }
1062
- },
1063
-
1064
- /**
1065
- * @param {Object} inst
1066
- * @param {number} x
1067
- * @param {number} y
1068
- */
1069
- _savePositionPixels: function (inst, x, y) {
1070
- if (!inst.state.persistPosition) return;
1071
- const container = this._containerOf(inst);
1072
- if (!container) return;
1073
- const key = this._persistKeyForInstance(inst, container);
1074
- const val = JSON.stringify({ x, y });
1075
- if (typeof window.safeStorageSet === 'function') {
1076
- window.safeStorageSet(key, val);
1077
- } else {
1078
- try {
1079
- localStorage.setItem(key, val);
1080
- } catch (_e) { /* ignore */ }
1081
- }
1082
- },
1083
-
1084
- /**
1085
- * @param {Object} inst
1086
- * @param {HTMLElement} container
1087
- * @returns {string}
1088
- */
1089
- _persistKeyForInstance: function (inst, container) {
1090
- const pk = inst.state.persistKey;
1091
- if (pk && String(pk).trim()) return persistStorageKey(String(pk).trim());
1092
- return persistStorageKey(container.id || '');
1093
- },
1094
-
1095
- /**
1096
- * @param {Object} inst
1097
- */
1098
- _unbindFloatingDrag: function (inst) {
1099
- if (inst.ui && typeof inst.ui.unbindDrag === 'function') {
1100
- inst.ui.unbindDrag();
1101
- inst.ui.unbindDrag = null;
1102
- }
1103
- },
1104
-
1105
- /**
1106
- * Free-form pointer drag on the handle. Vanduo's `draggable` component uses HTML5
1107
- * drag/drop for list reordering; floating players use pointer events on the handle instead.
1108
- * @param {Object} inst
1109
- */
1110
- _bindFloatingDrag: function (inst) {
1111
- this._unbindFloatingDrag(inst);
1112
- const h = inst.refs && inst.refs.dragHandle;
1113
- if (!h || !inst.state || !inst.state.draggable) return;
1114
- const self = this;
1115
- const container = this._containerOf(inst);
1116
- if (!container) return;
1117
- let startX = 0;
1118
- let startY = 0;
1119
- let origL = 0;
1120
- let origT = 0;
1121
- let activeDrag = false;
1122
- const onDown = function (e) {
1123
- if (e.pointerType === 'mouse' && e.button !== 0) return;
1124
- e.preventDefault();
1125
- activeDrag = true;
1126
- const r = container.getBoundingClientRect();
1127
- origL = r.left;
1128
- origT = r.top;
1129
- startX = e.clientX;
1130
- startY = e.clientY;
1131
- self._setCustomPositionFromRect(container, origL, origT);
1132
- try {
1133
- h.setPointerCapture(e.pointerId);
1134
- } catch (_err) { /* empty */ }
1135
- };
1136
- const onMove = function (e) {
1137
- if (!activeDrag) return;
1138
- const dx = e.clientX - startX;
1139
- const dy = e.clientY - startY;
1140
- let nl = origL + dx;
1141
- let nt = origT + dy;
1142
- const r = container.getBoundingClientRect();
1143
- const w = r.width;
1144
- const ph = r.height;
1145
- const vw = window.innerWidth;
1146
- const vh = window.innerHeight;
1147
- const pad = 8;
1148
- nl = Math.max(pad, Math.min(nl, vw - w - pad));
1149
- nt = Math.max(pad, Math.min(nt, vh - ph - pad));
1150
- self._setCustomPositionFromRect(container, nl, nt);
1151
- };
1152
- const onUp = function (e) {
1153
- if (!activeDrag) return;
1154
- activeDrag = false;
1155
- if (typeof h.hasPointerCapture === 'function' && h.hasPointerCapture(e.pointerId)) {
1156
- try {
1157
- h.releasePointerCapture(e.pointerId);
1158
- } catch (_e) { /* empty */ }
1159
- }
1160
- if (inst.state.persistPosition) {
1161
- const r = container.getBoundingClientRect();
1162
- self._savePositionPixels(inst, r.left, r.top);
1163
- }
1164
- };
1165
- h.addEventListener('pointerdown', onDown);
1166
- h.addEventListener('pointermove', onMove);
1167
- h.addEventListener('pointerup', onUp);
1168
- h.addEventListener('pointercancel', onUp);
1169
- inst.ui = inst.ui || { restore: null, unbindDrag: null };
1170
- inst.ui.unbindDrag = function () {
1171
- h.removeEventListener('pointerdown', onDown);
1172
- h.removeEventListener('pointermove', onMove);
1173
- h.removeEventListener('pointerup', onUp);
1174
- h.removeEventListener('pointercancel', onUp);
1175
- };
1176
- },
1177
-
1178
- /**
1179
- * Return a shallow copy of the current player state.
1180
- * @param {HTMLElement} container
1181
- * @returns {Object|null}
1182
- */
1183
- getState: function (container) {
1184
- const inst = this.instances.get(container);
1185
- if (!inst) return null;
1186
- const s = inst.state;
1187
- return {
1188
- isPlaying: s.isPlaying,
1189
- currentIndex: s.currentIndex,
1190
- currentTrack: s.tracks[s.currentIndex] || null,
1191
- volume: s.volume,
1192
- shuffle: s.shuffle,
1193
- tracks: s.tracks.slice(),
1194
- isDetached: Boolean(s.isDetached),
1195
- isMinimized: Boolean(s.isMinimized),
1196
- };
1197
- },
1198
-
1199
- /**
1200
- * Stop playback, clean up listeners, remove instance.
1201
- * @param {HTMLElement} container
1202
- */
1203
- destroy: function (container) {
1204
- const inst = this.instances.get(container);
1205
- if (!inst) return;
1206
- this._unbindFloatingDrag(inst);
1207
- if (inst.state && inst.state.isDetached) {
1208
- try {
1209
- this.attach(container);
1210
- } catch (_e) { /* empty */ }
1211
- }
1212
- inst.cleanup.forEach((fn) => fn());
1213
- this.instances.delete(container);
1214
- container.removeAttribute('data-music-player-initialized');
1215
- },
1216
-
1217
- /**
1218
- * Destroy all instances.
1219
- */
1220
- destroyAll: function () {
1221
- this.instances.forEach((_, container) => this.destroy(container));
1222
- },
1223
-
1224
- /* ─── Internal helpers ────────────────────────────────── */
1225
-
1226
- /**
1227
- * Load track by index on an already-initialised instance object.
1228
- * @param {Object} inst
1229
- * @param {number} index
1230
- * @param {boolean} autoPlay
1231
- */
1232
- _loadTrack: function (inst, index, autoPlay) {
1233
- const track = inst.state.tracks[index];
1234
- if (!track) return;
1235
- const container = this._containerOf(inst);
1236
-
1237
- inst.state.currentIndex = index;
1238
- inst.audio.src = track.url;
1239
-
1240
- if (inst.refs.trackName) {
1241
- inst.refs.trackName.textContent = track.name || 'Unknown Track';
1242
- inst.refs.trackName.classList.remove('is-idle');
1243
- }
1244
-
1245
- // Update playlist highlights
1246
- if (inst.refs.playlistPanel) {
1247
- inst.refs.playlistPanel.querySelectorAll('.vd-music-player-playlist-item').forEach((item, i) => {
1248
- const active = i === index;
1249
- item.classList.toggle('is-active', active);
1250
- item.setAttribute('aria-current', active ? 'true' : 'false');
1251
- });
1252
- }
1253
-
1254
- // Reset progress
1255
- if (inst.refs.progressBar) {
1256
- inst.refs.progressBar.value = '0';
1257
- updateRangeFill(inst.refs.progressBar);
1258
- }
1259
- if (inst.refs.timeElapsed) inst.refs.timeElapsed.textContent = '0:00';
1260
- if (inst.refs.timeDuration) inst.refs.timeDuration.textContent = '0:00';
1261
-
1262
- if (container) {
1263
- container.dispatchEvent(
1264
- new CustomEvent('musicplayer:trackchange', {
1265
- bubbles: true,
1266
- detail: { index, name: track.name, url: track.url },
1267
- })
1268
- );
1269
- }
1270
-
1271
- if (autoPlay) inst.audio.play().catch(() => {});
1272
- },
1273
-
1274
- /**
1275
- * Reverse-lookup the container element for a given instance object.
1276
- * @param {Object} inst
1277
- * @returns {HTMLElement|null}
1278
- */
1279
- _containerOf: function (inst) {
1280
- for (const [container, i] of this.instances) {
1281
- if (i === inst) return container;
1282
- }
1283
- return null;
1284
- },
1285
- };
1286
-
1287
- // Register with Vanduo framework
1288
- if (typeof window.Vanduo !== 'undefined') {
1289
- window.Vanduo.register('musicPlayer', MusicPlayer);
1290
- }
1291
-
1292
- // Convenience global
1293
- window.VanduoMusicPlayer = MusicPlayer;
1294
- })();