249 lines
9.7 KiB
JavaScript
249 lines
9.7 KiB
JavaScript
// Music player — three tracks, play/pause/skip/seek/volume.
|
|
|
|
const TRACKS = [
|
|
{ title: 'Grace Ascent', artist: 'Meridian OST', src: 'https://cdn.pixabay.com/audio/2022/10/18/audio_31c1679d6c.mp3' },
|
|
{ title: 'Siege Breaker', artist: 'Meridian OST', src: 'https://cdn.pixabay.com/audio/2022/11/17/audio_febc508520.mp3' },
|
|
{ title: 'Final Whistle', artist: 'Meridian OST', src: 'https://cdn.pixabay.com/audio/2023/06/13/audio_2c8e3b31b0.mp3' },
|
|
];
|
|
|
|
export default {
|
|
id: 'music',
|
|
title: 'Music',
|
|
render(container, api) {
|
|
const audio = new Audio();
|
|
let currentTrack = 0;
|
|
let playedSeconds = 0;
|
|
let playSecondsTimer = null;
|
|
|
|
const root = document.createElement('div');
|
|
root.style.cssText = 'height:100%;display:flex;overflow:hidden;';
|
|
|
|
// Left panel: track list
|
|
const listPanel = document.createElement('div');
|
|
listPanel.style.cssText = 'width:220px;background:var(--bg-2);border-right:1px solid var(--border);display:flex;flex-direction:column;overflow-y:auto;flex-shrink:0;';
|
|
|
|
const trackList = TRACKS.map((track, i) => {
|
|
const row = document.createElement('div');
|
|
row.style.cssText = 'padding:10px 14px;cursor:pointer;display:flex;flex-direction:column;border-left:2px solid transparent;border-bottom:1px solid rgba(255,255,255,0.04);transition:all 150ms ease-out;';
|
|
const titleSpan = document.createElement('div');
|
|
titleSpan.style.cssText = 'font-size:13px;font-weight:500;color:var(--text);';
|
|
titleSpan.textContent = track.title;
|
|
const artistSpan = document.createElement('div');
|
|
artistSpan.style.cssText = 'font-size:11px;color:var(--text-dim);margin-top:2px;';
|
|
artistSpan.textContent = track.artist;
|
|
row.appendChild(titleSpan);
|
|
row.appendChild(artistSpan);
|
|
row.addEventListener('click', () => loadTrack(i));
|
|
return { row, titleSpan, artistSpan };
|
|
});
|
|
|
|
TRACKS.forEach((_, i) => listPanel.appendChild(trackList[i].row));
|
|
|
|
// Right panel: now playing
|
|
const nowPanel = document.createElement('div');
|
|
nowPanel.style.cssText = 'flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:40px 24px;background:var(--bg-1);overflow-y:auto;';
|
|
|
|
const cover = document.createElement('div');
|
|
cover.style.cssText = 'width:120px;height:120px;background:rgba(212,162,76,0.15);border:1px solid rgba(212,162,76,0.2);display:flex;align-items:center;justify-content:center;font-size:48px;font-weight:600;color:var(--accent);margin-bottom:24px;';
|
|
|
|
const nowTitle = document.createElement('div');
|
|
nowTitle.style.cssText = 'font-size:28px;font-weight:600;color:var(--text);margin-bottom:6px;text-align:center;';
|
|
|
|
const nowArtist = document.createElement('div');
|
|
nowArtist.style.cssText = 'font-size:14px;color:var(--text-dim);margin-bottom:32px;text-align:center;';
|
|
|
|
// Controls row
|
|
const controls = document.createElement('div');
|
|
controls.style.cssText = 'display:flex;align-items:center;gap:20px;margin-bottom:28px;';
|
|
|
|
const btnStyle = 'width:40px;height:40px;border-radius:50%;background:var(--bg-3);border:1px solid var(--border);color:var(--text);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:16px;transition:background 150ms ease-out;';
|
|
const prevBtn = document.createElement('button');
|
|
prevBtn.style.cssText = btnStyle;
|
|
prevBtn.textContent = '<<';
|
|
prevBtn.addEventListener('click', () => { currentTrack = (currentTrack - 1 + TRACKS.length) % TRACKS.length; loadTrack(currentTrack); });
|
|
|
|
const playBtn = document.createElement('button');
|
|
playBtn.style.cssText = 'width:48px;height:48px;border-radius:50%;background:var(--accent);border:none;color:#0f1114;cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:20px;font-weight:700;transition:opacity 150ms ease-out;';
|
|
playBtn.textContent = '>';
|
|
|
|
const nextBtn = document.createElement('button');
|
|
nextBtn.style.cssText = btnStyle;
|
|
nextBtn.textContent = '>>';
|
|
nextBtn.addEventListener('click', () => { currentTrack = (currentTrack + 1) % TRACKS.length; loadTrack(currentTrack); });
|
|
|
|
controls.appendChild(prevBtn);
|
|
controls.appendChild(playBtn);
|
|
controls.appendChild(nextBtn);
|
|
|
|
// Seek bar
|
|
const seekContainer = document.createElement('div');
|
|
seekContainer.style.cssText = 'width:100%;max-width:400px;margin-bottom:16px;';
|
|
const seekLabel = document.createElement('div');
|
|
seekLabel.style.cssText = 'font-size:11px;color:var(--text-dim);margin-bottom:4px;display:flex;justify-content:space-between;';
|
|
const seekLabelLeft = document.createElement('span');
|
|
seekLabelLeft.textContent = '0:00';
|
|
const seekLabelRight = document.createElement('span');
|
|
seekLabelRight.textContent = '0:00';
|
|
seekLabel.appendChild(seekLabelLeft);
|
|
seekLabel.appendChild(seekLabelRight);
|
|
|
|
const seekInput = document.createElement('input');
|
|
seekInput.type = 'range';
|
|
seekInput.min = '0';
|
|
seekInput.max = '100';
|
|
seekInput.value = '0';
|
|
seekInput.style.cssText = 'width:100%;height:4px;-webkit-appearance:none;appearance:none;background:var(--bg-3);border-radius:2px;outline:none;cursor:pointer;';
|
|
seekInput.addEventListener('input', () => {
|
|
if (audio.duration) {
|
|
audio.currentTime = (seekInput.value / 100) * audio.duration;
|
|
}
|
|
});
|
|
seekContainer.appendChild(seekLabel);
|
|
seekContainer.appendChild(seekInput);
|
|
|
|
// Volume slider
|
|
const volContainer = document.createElement('div');
|
|
volContainer.style.cssText = 'display:flex;align-items:center;gap:8px;margin-top:12px;';
|
|
const volLabel = document.createElement('span');
|
|
volLabel.style.cssText = 'font-size:12px;color:var(--text-dim);';
|
|
volLabel.textContent = '\u{25A0}\u{25A0}';
|
|
const volInput = document.createElement('input');
|
|
volInput.type = 'range';
|
|
volInput.min = '0';
|
|
volInput.max = '100';
|
|
volInput.value = '70';
|
|
volInput.style.cssText = 'width:100px;height:4px;-webkit-appearance:none;appearance:none;background:var(--bg-3);border-radius:2px;outline:none;cursor:pointer;';
|
|
volInput.addEventListener('input', () => {
|
|
audio.volume = volInput.value / 100;
|
|
});
|
|
volContainer.appendChild(volLabel);
|
|
volContainer.appendChild(volInput);
|
|
|
|
nowPanel.appendChild(cover);
|
|
nowPanel.appendChild(nowTitle);
|
|
nowPanel.appendChild(nowArtist);
|
|
nowPanel.appendChild(controls);
|
|
nowPanel.appendChild(seekContainer);
|
|
nowPanel.appendChild(volContainer);
|
|
|
|
root.appendChild(listPanel);
|
|
root.appendChild(nowPanel);
|
|
container.appendChild(root);
|
|
|
|
function formatTime(s) {
|
|
if (!isFinite(s)) return '0:00';
|
|
return Math.floor(s / 60) + ':' + String(Math.floor(s % 60)).padStart(2, '0');
|
|
}
|
|
|
|
function updateUI() {
|
|
const track = TRACKS[currentTrack];
|
|
const urlOk = urlAvailable(track.src);
|
|
cover.textContent = track.title.charAt(0);
|
|
nowTitle.textContent = track.title;
|
|
nowArtist.textContent = track.artist;
|
|
playBtn.textContent = audio.paused ? '>' : '||';
|
|
|
|
// Highlight selected track
|
|
TRACKS.forEach((_, i) => {
|
|
const row = trackList[i].row;
|
|
if (i === currentTrack) {
|
|
row.style.borderLeftColor = 'var(--accent)';
|
|
row.style.background = 'var(--bg-3)';
|
|
} else {
|
|
row.style.borderLeftColor = 'transparent';
|
|
row.style.background = 'transparent';
|
|
}
|
|
});
|
|
|
|
seekLabelLeft.textContent = formatTime(audio.currentTime);
|
|
seekLabelRight.textContent = formatTime(audio.duration);
|
|
seekInput.value = audio.duration ? (audio.currentTime / audio.duration) * 100 : 0;
|
|
}
|
|
|
|
function loadTrack(index) {
|
|
stopPlaySecondsTimer();
|
|
currentTrack = index;
|
|
const track = TRACKS[index];
|
|
|
|
if (!urlAvailable(track.src)) {
|
|
nowTitle.textContent = track.title;
|
|
nowArtist.textContent = 'Track unavailable (offline)';
|
|
cover.textContent = track.title.charAt(0);
|
|
cover.style.background = 'rgba(150,150,150,0.15)';
|
|
playBtn.textContent = '>';
|
|
playBtn.disabled = true;
|
|
seekInput.value = 0;
|
|
seekLabelLeft.textContent = '0:00';
|
|
seekLabelRight.textContent = '0:00';
|
|
return;
|
|
}
|
|
|
|
cover.style.background = 'rgba(212,162,76,0.15)';
|
|
playBtn.disabled = false;
|
|
audio.src = track.src;
|
|
audio.volume = volInput.value / 100;
|
|
audio.load();
|
|
updateUI();
|
|
}
|
|
|
|
playBtn.addEventListener('click', () => {
|
|
if (playBtn.disabled) return;
|
|
if (!audio.src) return;
|
|
if (audio.paused) {
|
|
audio.play().then(() => {
|
|
api.bus.emit('music:play');
|
|
updateUI();
|
|
}).catch(() => {
|
|
updateUI();
|
|
});
|
|
startPlaySecondsTimer();
|
|
} else {
|
|
audio.pause();
|
|
updateUI();
|
|
stopPlaySecondsTimer();
|
|
}
|
|
});
|
|
|
|
audio.addEventListener('timeupdate', updateUI);
|
|
audio.addEventListener('ended', () => {
|
|
currentTrack = (currentTrack + 1) % TRACKS.length;
|
|
loadTrack(currentTrack);
|
|
audio.play().then(() => {
|
|
updateUI();
|
|
startPlaySecondsTimer();
|
|
}).catch(() => { updateUI(); });
|
|
});
|
|
audio.addEventListener('error', () => {
|
|
nowArtist.textContent = 'Track unavailable (offline)';
|
|
playBtn.disabled = true;
|
|
updateUI();
|
|
stopPlaySecondsTimer();
|
|
});
|
|
|
|
updateUI();
|
|
loadTrack(0);
|
|
audio.pause();
|
|
|
|
function urlAvailable(url) {
|
|
try {
|
|
const u = new URL(url);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function startPlaySecondsTimer() {
|
|
stopPlaySecondsTimer();
|
|
playSecondsTimer = setInterval(() => {
|
|
playedSeconds++;
|
|
api.bus.emit('music:played', { seconds: playedSeconds });
|
|
}, 5000);
|
|
}
|
|
|
|
function stopPlaySecondsTimer() {
|
|
clearInterval(playSecondsTimer);
|
|
}
|
|
},
|
|
};
|