@vanduo-oss/framework 1.4.2 → 1.4.4
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 +15 -3
- package/css/components/theme-switcher.css +129 -0
- package/css/vanduo.css +1 -1
- package/dist/build-info.json +3 -3
- package/dist/vanduo.cjs.js +328 -1098
- package/dist/vanduo.cjs.js.map +3 -3
- package/dist/vanduo.cjs.min.js +8 -8
- package/dist/vanduo.cjs.min.js.map +4 -4
- package/dist/vanduo.css +117 -690
- package/dist/vanduo.css.map +1 -1
- package/dist/vanduo.esm.js +328 -1098
- package/dist/vanduo.esm.js.map +3 -3
- package/dist/vanduo.esm.min.js +8 -8
- package/dist/vanduo.esm.min.js.map +4 -4
- package/dist/vanduo.js +328 -1098
- 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 +8 -8
- package/dist/vanduo.min.js.map +4 -4
- package/js/components/code-snippet.js +87 -24
- package/js/components/datepicker.js +27 -1
- package/js/components/suggest.js +18 -15
- package/js/components/theme-switcher.js +290 -29
- package/js/index.js +0 -1
- package/package.json +5 -5
- package/css/components/music-player.css +0 -798
- package/js/components/music-player.js +0 -1294
|
@@ -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
|
-
})();
|