<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>AlbumView Music Player</title>
<style>
/* --- CSS for Styling --- */
:root {
--primary: #007aff; /* Apple-like blue */
--background: #f4f6f8;
--card-bg: #ffffff;
--text-color: #1c1c1e;
--border-color: #e0e0e0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background-color: var(--background);
color: var(--text-color);
margin: 0;
padding: 20px;
display: flex;
flex-direction: column;
align-items: center;
}
/* Container for the entire application */
#app-container {
display: flex;
width: 90%;
max-width: 1200px;
min-height: 80vh;
background-color: var(--card-bg);
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
/* --- Sidebar (Albums List) --- */
#album-sidebar {
width: 300px;
padding: 15px;
border-right: 1px solid var(--border-color);
overflow-y: auto;
background-color: #f9f9f9;
}
#album-sidebar h2 {
margin-top: 0;
font-size: 1.5em;
color: var(--primary);
border-bottom: 2px solid var(--primary);
padding-bottom: 5px;
margin-bottom: 15px;
}
.album-item {
padding: 10px;
margin-bottom: 5px;
border-radius: 6px;
cursor: pointer;
transition: background-color 0.2s, transform 0.1s;
font-size: 0.95em;
display: flex;
flex-direction: column;
border: 1px solid transparent;
}
.album-item:hover {
background-color: #e8e8e8;
}
.album-item.active {
background-color: var(--primary);
color: white;
font-weight: bold;
}
.album-title {
font-weight: 600;
}
.album-artist {
font-size: 0.8em;
opacity: 0.8;
margin-top: 2px;
}
.album-item.active .album-artist {
color: white;
opacity: 0.9;
}
/* --- Main Content (Track List) --- */
#main-content {
flex-grow: 1;
display: flex;
flex-direction: column;
}
#track-list-container {
flex-grow: 1;
padding: 20px;
overflow-y: auto;
}
#track-list-container h3 {
font-size: 1.3em;
margin-top: 0;
margin-bottom: 15px;
border-bottom: 1px solid var(--border-color);
padding-bottom: 5px;
color: var(--text-color);
}
.track-item {
display: flex;
padding: 10px;
cursor: pointer;
transition: background-color 0.1s;
border-radius: 4px;
}
.track-item:nth-child(even) {
background-color: #fcfcfc;
}
.track-item:hover {
background-color: #e0f0ff;
}
.track-item.playing {
background-color: var(--primary);
color: white;
font-weight: 600;
}
.track-number {
width: 30px;
text-align: right;
margin-right: 15px;
font-style: italic;
}
.track-details {
flex-grow: 1;
}
/* --- Player Controls Bar --- */
#player-controls {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 20px;
border-top: 1px solid var(--border-color);
background-color: #ffffff;
height: 70px;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.05);
}
#current-track-info {
width: 30%;
display: flex;
flex-direction: column;
font-size: 0.9em;
}
#current-title {
font-weight: bold;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
#current-artist {
opacity: 0.7;
}
#playback-buttons {
display: flex;
gap: 20px;
align-items: center;
}
#playback-buttons button {
background: none;
border: none;
cursor: pointer;
font-size: 1.8em;
color: var(--text-color);
transition: color 0.2s, transform 0.1s;
}
#playback-buttons button:hover {
color: var(--primary);
transform: scale(1.05);
}
#playback-buttons #play-pause-btn {
font-size: 2.5em;
}
#volume-control {
width: 30%;
display: flex;
align-items: center;
justify-content: flex-end;
}
#volume-control span {
margin-right: 5px;
}
#volume-slider {
width: 100px;
cursor: grab;
}
/* Hidden file input, styled as a button */
#file-input-label {
display: inline-block;
background-color: var(--primary);
color: white;
padding: 8px 15px;
border-radius: 6px;
cursor: pointer;
font-weight: 500;
transition: background-color 0.2s;
margin-bottom: 20px;
}
#file-input-label:hover {
background-color: #005bb5;
}
#file-input {
display: none;
}
.loading-message {
text-align: center;
padding: 50px;
font-style: italic;
color: #6a6a6a;
}
#track-list-header {
display: flex;
justify-content: space-between;
align-items: center;
}
#track-list-header h3 {
flex-grow: 1;
}
</style>
<script src="https://cdn.jsdelivr.net/npm/jsmediatags@3.9.7/dist/jsmediatags.min.js"></script>
</head>
<body>
<input type="file" id="file-input" accept=".mp3,audio/*" multiple>
<label for="file-input" id="file-input-label">📁 Load Music Files (MP3, WAV, etc.)</label>
<div id="app-container">
<div id="album-sidebar">
<h2>Albums</h2>
<div id="albums-list">
<p class="loading-message">Load some music files to see your albums here.</p>
</div>
</div>
<div id="main-content">
<div id="track-list-container">
<div id="track-list-header">
<h3 id="current-album-title">Select an Album</h3>
<button onclick="playAllTracks()" style="background: none; border: 1px solid var(--primary); color: var(--primary); padding: 5px 10px; border-radius: 4px; cursor: pointer; transition: background-color 0.2s;">▶ Play Album</button>
</div>
<div id="tracks-view">
<p class="loading-message">Tracks for the selected album will appear here.</p>
</div>
</div>
<div id="player-controls">
<div id="current-track-info">
<span id="current-title">Not Playing</span>
<span id="current-artist">Load files to begin</span>
</div>
<div id="playback-buttons">
<button id="prev-btn">⏮</button>
<button id="play-pause-btn">▶</button>
<button id="next-btn">⏭</button>
</div>
<div id="volume-control">
<span>🔊</span>
<input type="range" id="volume-slider" min="0" max="1" step="0.05" value="1">
</div>
</div>
</div>
</div>
<script>
// --- JavaScript for Logic ---
const fileInput = document.getElementById('file-input');
const albumsListDiv = document.getElementById('albums-list');
const tracksViewDiv = document.getElementById('tracks-view');
const currentAlbumTitle = document.getElementById('current-album-title');
const currentTitleSpan = document.getElementById('current-title');
const currentArtistSpan = document.getElementById('current-artist');
const playPauseBtn = document.getElementById('play-pause-btn');
const prevBtn = document.getElementById('prev-btn');
const nextBtn = document.getElementById('next-btn');
const volumeSlider = document.getElementById('volume-slider');
// Audio object is the core of the player
const audio = new Audio();
// Data structure to hold all music files, grouped by a unique album key
// Structure: { 'Album Name - Artist Name': [ {file: File, tags: {}}, ... ], ... }
let library = {};
// Array of all tracks in the currently active album
let currentAlbumTracks = [];
let currentTrackIndex = -1;
let activeAlbumKey = null;
/**
* 1. Load Files and Read Metadata
*/
fileInput.addEventListener('change', async (event) => {
const files = event.target.files;
if (files.length === 0) return;
// Clear previous library and UI
library = {};
albumsListDiv.innerHTML = '<p class="loading-message">Processing files... Please wait.</p>';
const fileArray = Array.from(files);
let processedCount = 0;
// Process each file to get its metadata
for (const file of fileArray) {
if (!file.type.startsWith('audio/')) {
processedCount++;
continue;
}
try {
const tags = await getTags(file);
// Sanitize album/artist names for consistent grouping
const album = tags.album || 'Unknown Album';
const artist = tags.artist || 'Unknown Artist';
const key = `${album.trim()} - ${artist.trim()}`;
if (!library[key]) {
library[key] = {
album: album,
artist: artist,
tracks: []
};
}
library[key].tracks.push({
file: file,
tags: tags,
// Create a URL for the audio object to play from
url: URL.createObjectURL(file)
});
} catch (error) {
console.error('Error reading tags for file:', file.name, error);
// Add file even if tags fail, using filename as fallback
const key = `Error Tagged: ${file.name}`;
if (!library[key]) {
library[key] = {
album: 'Error Tagged',
artist: file.name,
tracks: []
};
}
library[key].tracks.push({
file: file,
tags: {title: file.name, artist: 'Unknown'},
url: URL.createObjectURL(file)
});
}
processedCount++;
}
renderAlbumList();
});
// Helper function to promisify jsmediatags
function getTags(file) {
return new Promise((resolve, reject) => {
jsmediatags.read(file, {
onSuccess: (tag) => {
const tags = tag.tags;
resolve({
title: tags.title || file.name.replace(/\.[^/.]+$/, ""), // Fallback to filename
artist: tags.artist || 'Unknown Artist',
album: tags.album || 'Unknown Album',
track: tags.track ? tags.track.split('/')[0] : null // Clean up track number
});
},
onError: (error) => {
reject(error);
}
});
});
}
/**
* 2. UI Rendering (Albums and Tracks)
*/
function renderAlbumList() {
const albumKeys = Object.keys(library).sort();
albumsListDiv.innerHTML = ''; // Clear existing list
if (albumKeys.length === 0) {
albumsListDiv.innerHTML = '<p class="loading-message">No valid audio files were found.</p>';
return;
}
albumKeys.forEach(key => {
const albumData = library[key];
const albumEl = document.createElement('div');
albumEl.className = 'album-item';
albumEl.dataset.key = key;
albumEl.innerHTML = `
<span class="album-title">${albumData.album}</span>
<span class="album-artist">${albumData.artist}</span>
`;
albumEl.addEventListener('click', () => selectAlbum(key));
albumsListDiv.appendChild(albumEl);
});
// Auto-select the first album if none is active
if (!activeAlbumKey && albumKeys.length > 0) {
selectAlbum(albumKeys[0]);
} else if (activeAlbumKey && library[activeAlbumKey]) {
// Re-select active album if it still exists
selectAlbum(activeAlbumKey);
}
}
function selectAlbum(key) {
if (activeAlbumKey) {
document.querySelector(`.album-item[data-key="${activeAlbumKey}"]`)?.classList.remove('active');
}
activeAlbumKey = key;
currentAlbumTracks = library[key].tracks.sort((a, b) => {
// Sort by track number if available, otherwise by title
const trackA = parseInt(a.tags.track) || Infinity;
const trackB = parseInt(b.tags.track) || Infinity;
if (trackA !== Infinity && trackB !== Infinity) {
return trackA - trackB;
}
return a.tags.title.localeCompare(b.tags.title);
});
const albumEl = document.querySelector(`.album-item[data-key="${key}"]`);
if (albumEl) {
albumEl.classList.add('active');
}
currentAlbumTitle.textContent = `${library[key].album} by ${library[key].artist}`;
renderTrackList();
}
function renderTrackList() {
tracksViewDiv.innerHTML = '';
currentAlbumTracks.forEach((track, index) => {
const trackEl = document.createElement('div');
trackEl.className = 'track-item';
trackEl.dataset.index = index;
trackEl.innerHTML = `
<span class="track-number">${track.tags.track || (index + 1)}</span>
<span class="track-details">${track.tags.title}</span>
`;
trackEl.addEventListener('click', () => playTrack(index));
tracksViewDiv.appendChild(trackEl);
});
highlightCurrentTrack();
}
/**
* 3. Playback Logic
*/
function playTrack(index) {
if (index < 0 || index >= currentAlbumTracks.length) return;
currentTrackIndex = index;
const track = currentAlbumTracks[currentTrackIndex];
// Set the audio source and play
audio.src = track.url;
audio.play();
// Update UI
currentTitleSpan.textContent = track.tags.title;
currentArtistSpan.textContent = track.tags.artist;
playPauseBtn.textContent = '⏸';
highlightCurrentTrack();
}
// Function called from the "Play Album" button
function playAllTracks() {
if (currentAlbumTracks.length > 0) {
playTrack(0);
}
}
function togglePlayPause() {
if (audio.paused) {
if (currentTrackIndex === -1 && currentAlbumTracks.length > 0) {
// Start from the beginning if nothing is selected
playTrack(0);
} else {
audio.play();
playPauseBtn.textContent = '⏸';
}
} else {
audio.pause();
playPauseBtn.textContent = '▶';
}
}
function playNext() {
const nextIndex = currentTrackIndex + 1;
if (nextIndex < currentAlbumTracks.length) {
playTrack(nextIndex);
} else {
// Stop or loop back to the beginning (for simple play functionality)
audio.pause();
audio.src = currentAlbumTracks[0].url; // Reset to the first track's source
currentTrackIndex = -1;
playPauseBtn.textContent = '▶';
highlightCurrentTrack();
}
}
function playPrevious() {
const prevIndex = currentTrackIndex - 1;
if (prevIndex >= 0) {
playTrack(prevIndex);
} else {
// Stay on the first track
audio.currentTime = 0;
}
}
function highlightCurrentTrack() {
document.querySelectorAll('.track-item').forEach(el => {
el.classList.remove('playing');
});
const currentTrackEl = document.querySelector(`.track-item[data-index="${currentTrackIndex}"]`);
if (currentTrackEl) {
currentTrackEl.classList.add('playing');
// Scroll into view if off-screen
currentTrackEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}
/**
* 4. Event Listeners & Player Setup
*/
playPauseBtn.addEventListener('click', togglePlayPause);
prevBtn.addEventListener('click', playPrevious);
nextBtn.addEventListener('click', playNext);
volumeSlider.addEventListener('input', (e) => {
audio.volume = e.target.value;
});
// Event for when a song finishes
audio.addEventListener('ended', playNext);
// Update UI when audio is paused/playing from external events (like hitting the space bar)
audio.addEventListener('play', () => {
playPauseBtn.textContent = '⏸';
});
audio.addEventListener('pause', () => {
playPauseBtn.textContent = '▶';
});
// Set initial volume
audio.volume = volumeSlider.value;
</script>
</body>
</html>