File structure:
/songs:
/xyz/
xyz_cover.jpeg
xyz.json
xyz.mp3
HTML:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Local Music Folder Player</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
</head>
<body>
<h1>Local Music Player</h1>
<div id="status">Click anywhere to select your "songs" folder...</div>
<div class="song-list" id="songList"></div>
<div id="player-bar">
<img id="now-cover" src="" alt="cover"/>
<div id="now-info">
<div id="now-title">—</div>
<div id="now-artist">—</div>
</div>
<audio id="audio" controls></audio>
</div>
<script src="song.js"></script>
</body>
</html>
JAVASCRIPT
const status = document.getElementById('status');
const songList = document.getElementById('songList');
const playerBar = document.getElementById('player-bar');
const nowCover = document.getElementById('now-cover');
const nowTitle = document.getElementById('now-title');
const nowArtist = document.getElementById('now-artist');
const audio = document.getElementById('audio');
let currentSong = null;
document.addEventListener('click', initApp, { once: true });
async function initApp() {
status.textContent = 'Opening folder picker...';
let dirHandle;
try {
dirHandle = await window.showDirectoryPicker({ mode: 'read' });
} catch (err) {
status.textContent = 'Folder access cancelled or failed :(';
return;
}
status.textContent = 'Scanning songs... please wait';
const songs = [];
for await (const entry of dirHandle.values()) {
if (entry.kind !== 'directory') continue;
let mp3 = null, json = null, cover = null;
for await (const file of entry.values()) {
if (file.kind !== 'file') continue;
const name = file.name.toLowerCase();
if (name.endsWith('.mp3')) mp3 = file;
else if (name.endsWith('.json')) json = file;
else if (/\.(jpg|jpeg|png|webp)$/i.test(name)) {
const fname = file.name.toLowerCase();
if (
fname.includes('cover') ||
fname.includes('folder') ||
fname === `${entry.name.toLowerCase()}.jpg` ||
fname === `${entry.name.toLowerCase()}.jpeg` ||
fname === `${entry.name.toLowerCase()}.png` ||
fname === `${entry.name.toLowerCase()}.webp`
) {
cover = file;
}
}
}
if (!mp3 || !json) continue;
try {
const jsonFile = await json.getFile();
const text = await jsonFile.text();
const meta = JSON.parse(text);
const song = {
title: meta.title || entry.name,
artist: meta.artist || 'Unknown Artist',
mp3Handle: mp3,
coverHandle: cover,
folderName: entry.name
};
songs.push(song);
} catch (e) {
console.warn(`Bad JSON in ${entry.name}`, e);
}
}
if (songs.length === 0) {
status.innerHTML = '<span style="color:#f66">No valid songs found<br>(need both .mp3 + .json in each folder)</span>';
return;
}
status.textContent = `Found ${songs.length} songs........ Click to play`;
songList.innerHTML = '';
for (const song of songs) {
const el = document.createElement('div');
el.className = 'song';
el.innerHTML = `
<img class="cover" src="data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==" alt="cover"/>
<div class="info">
<div class="title">${song.title}</div>
<div class="artist">${song.artist}</div>
</div>
`;
const img = el.querySelector('img');
if (song.coverHandle) {
try {
const file = await song.coverHandle.getFile();
img.src = URL.createObjectURL(file);
song.coverUrl = img.src; // save for player
} catch {}
}
el.onclick = () => playSong(song, el);
songList.appendChild(el);
}
}
async function playSong(song, element) {
try {
// Highlight current
document.querySelectorAll('.song').forEach(s => s.classList.remove('playing'));
element.classList.add('playing');
const file = await song.mp3Handle.getFile();
const url = URL.createObjectURL(file);
audio.src = url;
audio.play().catch(err => {
alert('Playback failed: ' + err.message);
});
nowTitle.textContent = song.title;
nowArtist.textContent = song.artist;
if (song.coverUrl) {
nowCover.src = song.coverUrl;
} else {
nowCover.src = 'https://via.placeholder.com/56/222/888?text=♪';
}
playerBar.classList.add('show');
currentSong = song;
} catch (err) {
alert('Cannot load song: ' + err.message);
}
}
// Cleanup old blob urls when page unloads
window.addEventListener('unload', () => {
if (audio.src.startsWith('blob:')) {
URL.revokeObjectURL(audio.src);
}
});