Merge pull request #24 from JulienMaille/fav-lib

feat: add a library of liked song, artist, albums, playlists
This commit is contained in:
Julien 2025-12-26 23:18:47 +01:00 committed by GitHub
commit 9e6b4c2950
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 762 additions and 72 deletions

View file

@ -22,6 +22,7 @@
<audio id="audio-player"></audio> <audio id="audio-player"></audio>
<div id="context-menu"> <div id="context-menu">
<ul> <ul>
<li data-action="toggle-like">Like</li>
<li data-action="play-next">Play Next</li> <li data-action="play-next">Play Next</li>
<li data-action="add-to-queue">Add to Queue</li> <li data-action="add-to-queue">Add to Queue</li>
<li data-action="download">Download</li> <li data-action="download">Download</li>
@ -31,7 +32,10 @@
<div id="queue-modal"> <div id="queue-modal">
<div id="queue-modal-header"> <div id="queue-modal-header">
<h3>Queue</h3> <h3>Queue</h3>
<button id="close-queue-btn">&times;</button> <div style="display: flex; gap: 0.5rem; align-items: center;">
<button id="clear-queue-btn" class="btn-secondary">Clear All</button>
<button id="close-queue-btn">&times;</button>
</div>
</div> </div>
<div id="queue-list"></div> <div id="queue-list"></div>
</div> </div>
@ -77,6 +81,12 @@
<span>Home</span> <span>Home</span>
</a> </a>
</li> </li>
<li class="nav-item">
<a href="#library">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m16 6 4 14"/><path d="M12 6v14"/><path d="M8 8v12"/><path d="M4 4v16"/></svg>
<span>Library</span>
</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a href="#settings"> <a href="#settings">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@ -159,6 +169,28 @@
</div> </div>
</div> </div>
<div id="page-library" class="page">
<h2 class="section-title">Your Library</h2>
<div class="search-tabs">
<button class="search-tab active" data-tab="tracks">Liked Songs</button>
<button class="search-tab" data-tab="albums">Albums</button>
<button class="search-tab" data-tab="artists">Artists</button>
<button class="search-tab" data-tab="playlists">Playlists</button>
</div>
<div class="search-tab-content active" id="library-tab-tracks">
<div class="track-list" id="library-tracks-container"></div>
</div>
<div class="search-tab-content" id="library-tab-albums">
<div class="card-grid" id="library-albums-container"></div>
</div>
<div class="search-tab-content" id="library-tab-artists">
<div class="card-grid" id="library-artists-container"></div>
</div>
<div class="search-tab-content" id="library-tab-playlists">
<div class="card-grid" id="library-playlists-container"></div>
</div>
</div>
<div id="page-album" class="page"> <div id="page-album" class="page">
<header class="detail-header"> <header class="detail-header">
<img id="album-detail-image" src="" alt="" class="detail-header-image"> <img id="album-detail-image" src="" alt="" class="detail-header-image">
@ -173,6 +205,9 @@
<button id="download-album-btn" class="btn-primary"> <button id="download-album-btn" class="btn-primary">
<span>Download Album</span> <span>Download Album</span>
</button> </button>
<button id="like-album-btn" class="btn-secondary like-btn" data-action="toggle-like" data-type="album" title="Save to Library">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="heart-icon"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path></svg>
</button>
</div> </div>
</div> </div>
</header> </header>
@ -190,9 +225,13 @@
<button id="play-playlist-btn" class="btn-primary"> <button id="play-playlist-btn" class="btn-primary">
<span>Play</span> <span>Play</span>
</button> </button>
<!-- Like button not typically for own playlists, but good for "followed" ones if we support that. For now, maybe skip or add "Edit" if it's user playlist -->
<button id="download-playlist-btn" class="btn-primary"> <button id="download-playlist-btn" class="btn-primary">
<span>Download</span> <span>Download</span>
</button> </button>
<button id="like-playlist-btn" class="btn-secondary like-btn" data-action="toggle-like" data-type="playlist" title="Save to Library">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="heart-icon"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path></svg>
</button>
</div> </div>
</div> </div>
</div> </div>
@ -215,6 +254,9 @@
<button id="download-discography-btn" class="btn-primary"> <button id="download-discography-btn" class="btn-primary">
<span>Download Discography</span> <span>Download Discography</span>
</button> </button>
<button id="like-artist-btn" class="btn-secondary like-btn" data-action="toggle-like" data-type="artist" title="Save to Library">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="heart-icon"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path></svg>
</button>
</div> </div>
</div> </div>
</header> </header>
@ -370,6 +412,17 @@
</div> </div>
<button id="clear-cache-btn" class="btn-secondary">Clear Cache</button> <button id="clear-cache-btn" class="btn-secondary">Clear Cache</button>
</div> </div>
<div class="setting-item">
<div class="info">
<span class="label">Backup & Restore</span>
<span class="description">Export or import your library and playlists as JSON</span>
</div>
<div style="display: flex; gap: 0.5rem;">
<button id="export-library-btn" class="btn-secondary">Export</button>
<button id="import-library-btn" class="btn-secondary">Import</button>
<input type="file" id="import-library-input" style="display: none;" accept=".json">
</div>
</div>
<div id="api-instance-manager"> <div id="api-instance-manager">
<div class="setting-item" style="padding-bottom: 1rem; border: none;"> <div class="setting-item" style="padding-bottom: 1rem; border: none;">
<div class="info"> <div class="info">
@ -466,6 +519,8 @@
</div> </div>
</div> </div>
<div class="volume-controls"> <div class="volume-controls">
<button id="now-playing-like-btn" class="like-btn" data-action="toggle-like" title="Save to Library" style="display: none;">
</button>
<button id="download-current-btn" title="Download current track" class="desktop-only"> <button id="download-current-btn" title="Download current track" class="desktop-only">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>

View file

@ -30,7 +30,16 @@ function initializeCasting(audioPlayer, castBtn) {
}); });
castBtn.addEventListener('click', () => { castBtn.addEventListener('click', () => {
if (!audioPlayer.src) {
alert('Please play a track first to enable casting.');
return;
}
audioPlayer.remote.prompt().catch(err => { audioPlayer.remote.prompt().catch(err => {
if (err.name === 'NotAllowedError') return;
if (err.name === 'NotFoundError') {
alert('No remote playback devices (Chromecast/AirPlay) were found on your network.');
return;
}
console.log('Cast prompt error:', err); console.log('Cast prompt error:', err);
}); });
}); });
@ -191,13 +200,18 @@ document.addEventListener('DOMContentLoaded', async () => {
initializeSettings(scrobbler, player, api, ui); initializeSettings(scrobbler, player, api, ui);
initializePlayerEvents(player, audioPlayer, scrobbler); initializePlayerEvents(player, audioPlayer, scrobbler);
initializeTrackInteractions(player, api, document.querySelector('.main-content'), document.getElementById('context-menu'), lyricsManager); initializeTrackInteractions(player, api, document.querySelector('.main-content'), document.getElementById('context-menu'), lyricsManager, ui);
initializeUIInteractions(player, api); initializeUIInteractions(player, api);
initializeKeyboardShortcuts(player, audioPlayer, lyricsPanel); initializeKeyboardShortcuts(player, audioPlayer, lyricsPanel);
const castBtn = document.getElementById('cast-btn'); const castBtn = document.getElementById('cast-btn');
initializeCasting(audioPlayer, castBtn); initializeCasting(audioPlayer, castBtn);
// Restore UI state for the current track (like button, theme)
if (player.currentTrack) {
ui.setCurrentTrack(player.currentTrack);
}
document.querySelector('.now-playing-bar .cover').addEventListener('click', async () => { document.querySelector('.now-playing-bar .cover').addEventListener('click', async () => {
if (!player.currentTrack) { if (!player.currentTrack) {
alert('No track is currently playing'); alert('No track is currently playing');
@ -275,7 +289,7 @@ document.addEventListener('DOMContentLoaded', async () => {
document.getElementById('download-current-btn')?.addEventListener('click', () => { document.getElementById('download-current-btn')?.addEventListener('click', () => {
if (player.currentTrack) { if (player.currentTrack) {
handleTrackAction('download', player.currentTrack, player, api, lyricsManager); handleTrackAction('download', player.currentTrack, player, api, lyricsManager, 'track', ui);
} }
}); });

139
js/db.js Normal file
View file

@ -0,0 +1,139 @@
export class MusicDatabase {
constructor() {
this.dbName = 'MonochromeDB';
this.version = 2;
this.db = null;
}
async open() {
if (this.db) return this.db;
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.version);
request.onerror = (event) => {
console.error("Database error:", event.target.error);
reject(event.target.error);
};
request.onsuccess = (event) => {
this.db = event.target.result;
resolve(this.db);
};
request.onupgradeneeded = (event) => {
const db = event.target.result;
// Favorites stores
if (!db.objectStoreNames.contains('favorites_tracks')) {
const store = db.createObjectStore('favorites_tracks', { keyPath: 'id' });
store.createIndex('addedAt', 'addedAt', { unique: false });
}
if (!db.objectStoreNames.contains('favorites_albums')) {
const store = db.createObjectStore('favorites_albums', { keyPath: 'id' });
store.createIndex('addedAt', 'addedAt', { unique: false });
}
if (!db.objectStoreNames.contains('favorites_artists')) {
const store = db.createObjectStore('favorites_artists', { keyPath: 'id' });
store.createIndex('addedAt', 'addedAt', { unique: false });
}
if (!db.objectStoreNames.contains('favorites_playlists')) {
const store = db.createObjectStore('favorites_playlists', { keyPath: 'uuid' });
store.createIndex('addedAt', 'addedAt', { unique: false });
}
};
});
}
// Generic Helper
async performTransaction(storeName, mode, callback) {
const db = await this.open();
return new Promise((resolve, reject) => {
const transaction = db.transaction(storeName, mode);
const store = transaction.objectStore(storeName);
const request = callback(store);
transaction.oncomplete = () => {
resolve(request?.result);
};
transaction.onerror = (event) => {
reject(event.target.error);
};
});
}
// Favorites API
async toggleFavorite(type, item) {
const storeName = `favorites_${type}s`; // tracks, albums, artists
const key = type === 'playlist' ? item.uuid : item.id;
const exists = await this.isFavorite(type, key);
if (exists) {
await this.performTransaction(storeName, 'readwrite', (store) => store.delete(key));
return false; // Removed
} else {
const entry = { ...item, addedAt: Date.now() };
await this.performTransaction(storeName, 'readwrite', (store) => store.put(entry));
return true; // Added
}
}
async isFavorite(type, id) {
const storeName = `favorites_${type}s`;
try {
const result = await this.performTransaction(storeName, 'readonly', (store) => store.get(id));
return !!result;
} catch (e) {
return false;
}
}
async getFavorites(type) {
const storeName = `favorites_${type}s`;
const db = await this.open();
return new Promise((resolve, reject) => {
const transaction = db.transaction(storeName, 'readonly');
const store = transaction.objectStore(storeName);
const index = store.index('addedAt');
const request = index.getAll(); // Returns sorted by addedAt ascending
request.onsuccess = () => {
// Reverse to show newest first
resolve(request.result.reverse());
};
request.onerror = () => reject(request.error);
});
}
async exportData() {
const data = {
favorites_tracks: await this.getFavorites('track'),
favorites_albums: await this.getFavorites('album'),
favorites_artists: await this.getFavorites('artist'),
favorites_playlists: await this.getFavorites('playlist')
};
return data;
}
async importData(data) {
// Clear existing? Or merge? Prompt says "Sync" or "Export/Import".
// Let's merge by put (replaces if ID exists).
const db = await this.open();
const importStore = async (storeName, items) => {
if (!items || !Array.isArray(items)) return;
const transaction = db.transaction(storeName, 'readwrite');
const store = transaction.objectStore(storeName);
for (const item of items) {
store.put(item);
}
};
await importStore('favorites_tracks', data.favorites_tracks);
await importStore('favorites_albums', data.favorites_albums);
await importStore('favorites_artists', data.favorites_artists);
await importStore('favorites_playlists', data.favorites_playlists);
}
}
export const db = new MusicDatabase();

View file

@ -1,9 +1,10 @@
//js/events.js //js/events.js
import { SVG_PLAY, SVG_PAUSE, SVG_VOLUME, SVG_MUTE, REPEAT_MODE, trackDataStore } from './utils.js'; import { SVG_PLAY, SVG_PAUSE, SVG_VOLUME, SVG_MUTE, REPEAT_MODE, trackDataStore, RATE_LIMIT_ERROR_MESSAGE, buildTrackFilename, getTrackTitle } from './utils.js';
import { lastFMStorage } from './storage.js'; import { lastFMStorage } from './storage.js';
import { showNotification, downloadTrackWithMetadata } from './downloads.js'; import { showNotification, downloadTrackWithMetadata } from './downloads.js';
import { lyricsSettings } from './storage.js'; import { lyricsSettings } from './storage.js';
import { updateTabTitle } from './router.js'; import { updateTabTitle } from './router.js';
import { db } from './db.js';
export function initializePlayerEvents(player, audioPlayer, scrobbler) { export function initializePlayerEvents(player, audioPlayer, scrobbler) {
const playPauseBtn = document.querySelector('.play-pause-btn'); const playPauseBtn = document.querySelector('.play-pause-btn');
@ -249,9 +250,14 @@ function initializeSmoothSliders(audioPlayer, player) {
progressBar.addEventListener('click', e => { progressBar.addEventListener('click', e => {
if (!isSeeking) { if (!isSeeking) {
seek(progressBar, e, position => { seek(progressBar, e, position => {
if (!isNaN(audioPlayer.duration)) { if (!isNaN(audioPlayer.duration) && audioPlayer.duration > 0 && audioPlayer.duration !== Infinity) {
audioPlayer.currentTime = position * audioPlayer.duration; audioPlayer.currentTime = position * audioPlayer.duration;
player.updateMediaSessionPositionState(); player.updateMediaSessionPositionState();
} else if (player.currentTrack && player.currentTrack.duration) {
const targetTime = position * player.currentTrack.duration;
const progressFill = document.querySelector('.progress-fill');
if (progressFill) progressFill.style.width = `${position * 100}%`;
player.playTrackFromQueue(targetTime);
} }
}); });
} }
@ -290,33 +296,128 @@ function initializeSmoothSliders(audioPlayer, player) {
}); });
} }
export async function handleTrackAction(action, track, player, api, lyricsManager) { export async function handleTrackAction(action, item, player, api, lyricsManager, type = 'track', ui = null) {
if (!track) return; if (!item) return;
if (action === 'add-to-queue') { if (action === 'add-to-queue') {
player.addToQueue(track); player.addToQueue(item);
renderQueue(player); renderQueue(player);
showNotification(`Added to queue: ${track.title}`); showNotification(`Added to queue: ${item.title}`);
} else if (action === 'play-next') { } else if (action === 'play-next') {
player.addNextToQueue(track); player.addNextToQueue(item);
renderQueue(player); renderQueue(player);
showNotification(`Playing next: ${track.title}`); showNotification(`Playing next: ${item.title}`);
} else if (action === 'download') { } else if (action === 'download') {
await downloadTrackWithMetadata(track, player.quality, api, lyricsManager); await downloadTrackWithMetadata(item, player.quality, api, lyricsManager);
} else if (action === 'toggle-like') {
const added = await db.toggleFavorite(type, item);
// Update all instances of this item's like button on the page
const id = type === 'playlist' ? item.uuid : item.id;
const selector = type === 'track'
? `[data-track-id="${id}"] .like-btn`
: `.card[data-${type}-id="${id}"] .like-btn, .card[data-playlist-id="${id}"] .like-btn`;
// Also check header buttons
const headerBtn = document.getElementById(`like-${type}-btn`);
const elementsToUpdate = [...document.querySelectorAll(selector)];
if (headerBtn) elementsToUpdate.push(headerBtn);
const nowPlayingLikeBtn = document.getElementById('now-playing-like-btn');
if (nowPlayingLikeBtn && type === 'track' && player?.currentTrack?.id === item.id) {
elementsToUpdate.push(nowPlayingLikeBtn);
}
elementsToUpdate.forEach(btn => {
const heartIcon = btn.querySelector('svg');
if (heartIcon) {
heartIcon.classList.toggle('filled', added);
if (heartIcon.hasAttribute('fill')) {
heartIcon.setAttribute('fill', added ? 'currentColor' : 'none');
}
}
btn.classList.toggle('active', added);
btn.title = added ? 'Remove from Library' : 'Add to Library';
});
// Handle Library Page Update
if (window.location.hash === '#library') {
const itemSelector = type === 'track'
? `.track-item[data-track-id="${id}"]`
: `.card[data-${type}-id="${id}"], .card[data-playlist-id="${id}"]`;
const itemEl = document.querySelector(itemSelector);
if (!added && itemEl) {
// Remove item
const container = itemEl.parentElement;
itemEl.remove();
if (container && container.children.length === 0) {
const msg = type === 'track' ? 'No liked songs yet.' : `No liked ${type}s yet.`;
container.innerHTML = `<div class="placeholder-text">${msg}</div>`;
}
} else if (added && !itemEl && ui && type === 'track') {
// Add item (specifically for tracks currently)
const tracksContainer = document.getElementById('library-tracks-container');
if (tracksContainer) {
// Remove placeholder if it exists
const placeholder = tracksContainer.querySelector('.placeholder-text');
if (placeholder) placeholder.remove();
// Create track element
const index = tracksContainer.children.length;
const trackHTML = ui.createTrackItemHTML(item, index, true, false);
const tempDiv = document.createElement('div');
tempDiv.innerHTML = trackHTML;
const newEl = tempDiv.firstElementChild;
if (newEl) {
tracksContainer.appendChild(newEl);
trackDataStore.set(newEl, item);
ui.updateLikeState(newEl, 'track', item.id);
}
}
}
}
} }
} }
export function initializeTrackInteractions(player, api, mainContent, contextMenu, lyricsManager) { export function initializeTrackInteractions(player, api, mainContent, contextMenu, lyricsManager, ui) {
let contextTrack = null; let contextTrack = null;
mainContent.addEventListener('click', e => { mainContent.addEventListener('click', async e => {
const actionBtn = e.target.closest('.track-action-btn'); const actionBtn = e.target.closest('.track-action-btn, .like-btn');
if (actionBtn) { if (actionBtn && actionBtn.dataset.action) {
e.preventDefault(); // Prevent card navigation
e.stopPropagation(); e.stopPropagation();
const trackItem = actionBtn.closest('.track-item'); const itemElement = actionBtn.closest('.track-item, .card');
if (trackItem) { const action = actionBtn.dataset.action;
const track = trackDataStore.get(trackItem); const type = actionBtn.dataset.type || 'track';
handleTrackAction(actionBtn.dataset.action, track, player, api, lyricsManager);
let item = itemElement ? trackDataStore.get(itemElement) : null;
// If no item from element (e.g. header buttons), try to get from hash
if (!item && action === 'toggle-like') {
const id = window.location.hash.split('/')[1];
if (id) {
try {
if (type === 'album') {
const data = await api.getAlbum(id);
item = data.album;
} else if (type === 'artist') {
item = await api.getArtist(id);
} else if (type === 'playlist') {
const data = await api.getPlaylist(id);
item = data.playlist;
}
} catch (err) { console.error(err); }
}
}
if (item) {
await handleTrackAction(action, item, player, api, lyricsManager, type, ui);
} }
return; return;
} }
@ -328,6 +429,7 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
if (trackItem && !trackItem.dataset.queueIndex) { if (trackItem && !trackItem.dataset.queueIndex) {
contextTrack = trackDataStore.get(trackItem); contextTrack = trackDataStore.get(trackItem);
if (contextTrack) { if (contextTrack) {
await updateContextMenuLikeState(contextMenu, contextTrack);
const rect = menuBtn.getBoundingClientRect(); const rect = menuBtn.getBoundingClientRect();
positionMenu(contextMenu, rect.left, rect.bottom + 5, rect); positionMenu(contextMenu, rect.left, rect.bottom + 5, rect);
} }
@ -350,15 +452,28 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
player.playTrackFromQueue(); player.playTrackFromQueue();
} }
} }
const card = e.target.closest('.card');
if (card) {
const href = card.dataset.href;
if (href) {
// Allow native links inside card to work if any exist
if (e.target.closest('a')) return;
e.preventDefault();
window.location.hash = href;
}
}
}); });
mainContent.addEventListener('contextmenu', e => { mainContent.addEventListener('contextmenu', async e => {
const trackItem = e.target.closest('.track-item'); const trackItem = e.target.closest('.track-item');
if (trackItem && !trackItem.dataset.queueIndex) { if (trackItem && !trackItem.dataset.queueIndex) {
e.preventDefault(); e.preventDefault();
contextTrack = trackDataStore.get(trackItem); contextTrack = trackDataStore.get(trackItem);
if (contextTrack) { if (contextTrack) {
await updateContextMenuLikeState(contextMenu, contextTrack);
positionMenu(contextMenu, e.pageX, e.pageY); positionMenu(contextMenu, e.pageX, e.pageY);
} }
} }
@ -372,7 +487,7 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
e.stopPropagation(); e.stopPropagation();
const action = e.target.dataset.action; const action = e.target.dataset.action;
if (action && contextTrack) { if (action && contextTrack) {
await handleTrackAction(action, contextTrack, player, api, lyricsManager); await handleTrackAction(action, contextTrack, player, api, lyricsManager, 'track', ui);
} }
contextMenu.style.display = 'none'; contextMenu.style.display = 'none';
}); });
@ -391,6 +506,16 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
window.location.hash = `#artist/${track.artist.id}`; window.location.hash = `#artist/${track.artist.id}`;
} }
}); });
const nowPlayingLikeBtn = document.getElementById('now-playing-like-btn');
if (nowPlayingLikeBtn) {
nowPlayingLikeBtn.addEventListener('click', async (e) => {
e.stopPropagation();
if (player.currentTrack) {
await handleTrackAction('toggle-like', player.currentTrack, player, api, lyricsManager, 'track', ui);
}
});
}
} }
function renderQueue(player) { function renderQueue(player) {
@ -407,6 +532,14 @@ function formatTime(seconds) {
return `${m}:${String(s).padStart(2, '0')}`; return `${m}:${String(s).padStart(2, '0')}`;
} }
async function updateContextMenuLikeState(menu, track) {
const likeItem = menu.querySelector('[data-action="toggle-like"]');
if (likeItem) {
const isLiked = await db.isFavorite('track', track.id);
likeItem.textContent = isLiked ? 'Remove from Library' : 'Add to Library';
}
}
function positionMenu(menu, x, y, anchorRect = null) { function positionMenu(menu, x, y, anchorRect = null) {
// Temporarily show to measure dimensions // Temporarily show to measure dimensions
menu.style.visibility = 'hidden'; menu.style.visibility = 'hidden';

View file

@ -52,6 +52,8 @@ export class Player {
if (coverEl) coverEl.src = this.api.getCoverUrl(track.album?.cover, '1280'); if (coverEl) coverEl.src = this.api.getCoverUrl(track.album?.cover, '1280');
if (titleEl) titleEl.textContent = trackTitle; if (titleEl) titleEl.textContent = trackTitle;
if (artistEl) artistEl.textContent = trackArtists; if (artistEl) artistEl.textContent = trackArtists;
const totalDurationEl = document.getElementById('total-duration');
if (totalDurationEl) totalDurationEl.textContent = formatTime(track.duration);
document.title = `${trackTitle}${track.artist?.name || 'Unknown'}`; document.title = `${trackTitle}${track.artist?.name || 'Unknown'}`;
this.updatePlayingTrackIndicator(); this.updatePlayingTrackIndicator();
@ -154,7 +156,7 @@ export class Player {
} }
} }
async playTrackFromQueue() { async playTrackFromQueue(startTime = 0) {
const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue; const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue;
if (this.currentQueueIndex < 0 || this.currentQueueIndex >= currentQueue.length) { if (this.currentQueueIndex < 0 || this.currentQueueIndex >= currentQueue.length) {
return; return;
@ -193,6 +195,9 @@ export class Player {
} }
this.audio.src = streamUrl; this.audio.src = streamUrl;
if (startTime > 0) {
this.audio.currentTime = startTime;
}
await this.audio.play(); await this.audio.play();
this.updateMediaSessionPlaybackState(); this.updateMediaSessionPlaybackState();
@ -348,22 +353,37 @@ export class Player {
removeFromQueue(index) { removeFromQueue(index) {
const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue; const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue;
if (index < 0 || index >= currentQueue.length) return; // If removing current track
if (index === this.currentQueueIndex) {
if (this.shuffleActive) { // If playing, we might want to stop or just let it finish?
this.shuffledQueue.splice(index, 1); // For now, let's just remove it.
} else { // If it's the last track, playback will stop naturally or we handle it?
this.queue.splice(index, 1);
} }
if (index < this.currentQueueIndex) { if (index < this.currentQueueIndex) {
this.currentQueueIndex--; this.currentQueueIndex--;
} else if (index === this.currentQueueIndex) {
if (currentQueue.length > 0) {
this.playTrackFromQueue();
}
} }
const removedTrack = currentQueue.splice(index, 1)[0];
if (this.shuffleActive) {
// Also remove from original queue
const originalIndex = this.originalQueueBeforeShuffle.findIndex(t => t.id === removedTrack.id); // Simple ID check
if (originalIndex !== -1) {
this.originalQueueBeforeShuffle.splice(originalIndex, 1);
}
}
this.saveQueueState();
this.preloadNextTracks();
}
clearQueue() {
this.queue = [];
this.shuffledQueue = [];
this.originalQueueBeforeShuffle = [];
this.currentQueueIndex = -1;
this.saveQueueState(); this.saveQueueState();
} }

View file

@ -17,6 +17,9 @@ export function createRouter(ui) {
case 'playlist': case 'playlist':
ui.renderPlaylistPage(param); ui.renderPlaylistPage(param);
break; break;
case 'library':
ui.renderLibraryPage();
break;
case 'home': case 'home':
ui.renderHomePage(); ui.renderHomePage();
break; break;

View file

@ -1,5 +1,6 @@
//js/settings //js/settings
import { themeManager, lastFMStorage, nowPlayingSettings, lyricsSettings, backgroundSettings, trackListSettings } from './storage.js'; import { themeManager, lastFMStorage, nowPlayingSettings, lyricsSettings, backgroundSettings, trackListSettings } from './storage.js';
import { db } from './db.js';
export function initializeSettings(scrobbler, player, api, ui) { export function initializeSettings(scrobbler, player, api, ui) {
const lastfmConnectBtn = document.getElementById('lastfm-connect-btn'); const lastfmConnectBtn = document.getElementById('lastfm-connect-btn');
@ -289,4 +290,40 @@ export function initializeSettings(scrobbler, player, api, ui) {
}, 1500); }, 1500);
} }
}); });
// Backup & Restore
document.getElementById('export-library-btn')?.addEventListener('click', async () => {
const data = await db.exportData();
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `monochrome-library-${new Date().toISOString().split('T')[0]}.json`;
a.click();
URL.revokeObjectURL(url);
});
const importInput = document.getElementById('import-library-input');
document.getElementById('import-library-btn')?.addEventListener('click', () => {
importInput.click();
});
importInput?.addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async (event) => {
try {
const data = JSON.parse(event.target.result);
await db.importData(data);
alert('Library imported successfully!');
window.location.reload(); // Simple way to refresh all state
} catch (err) {
console.error('Import failed:', err);
alert('Failed to import library. Please check the file format.');
}
};
reader.readAsText(file);
});
} }

View file

@ -1,5 +1,5 @@
//js/ui-interactions.js //js/ui-interactions.js
import { SVG_CLOSE, formatTime, trackDataStore, getTrackTitle, getTrackArtists } from './utils.js'; import { SVG_CLOSE, SVG_BIN, formatTime, trackDataStore, getTrackTitle, getTrackArtists } from './utils.js';
export function initializeUIInteractions(player, api) { export function initializeUIInteractions(player, api) {
const sidebar = document.querySelector('.sidebar'); const sidebar = document.querySelector('.sidebar');
@ -8,6 +8,7 @@ export function initializeUIInteractions(player, api) {
const queueBtn = document.getElementById('queue-btn'); const queueBtn = document.getElementById('queue-btn');
const queueModalOverlay = document.getElementById('queue-modal-overlay'); const queueModalOverlay = document.getElementById('queue-modal-overlay');
const closeQueueBtn = document.getElementById('close-queue-btn'); const closeQueueBtn = document.getElementById('close-queue-btn');
const clearQueueBtn = document.getElementById('clear-queue-btn');
const queueList = document.getElementById('queue-list'); const queueList = document.getElementById('queue-list');
let draggedQueueIndex = null; let draggedQueueIndex = null;
@ -40,6 +41,13 @@ export function initializeUIInteractions(player, api) {
closeQueueBtn.addEventListener('click', () => { closeQueueBtn.addEventListener('click', () => {
queueModalOverlay.style.display = 'none'; queueModalOverlay.style.display = 'none';
}); });
if (clearQueueBtn) {
clearQueueBtn.addEventListener('click', () => {
player.clearQueue();
renderQueue();
});
}
queueModalOverlay.addEventListener('click', e => { queueModalOverlay.addEventListener('click', e => {
if (e.target === queueModalOverlay) { if (e.target === queueModalOverlay) {
@ -50,6 +58,10 @@ export function initializeUIInteractions(player, api) {
function renderQueue() { function renderQueue() {
const currentQueue = player.getCurrentQueue(); const currentQueue = player.getCurrentQueue();
if (clearQueueBtn) {
clearQueueBtn.style.display = currentQueue.length > 0 ? 'block' : 'none';
}
if (currentQueue.length === 0) { if (currentQueue.length === 0) {
queueList.innerHTML = '<div class="placeholder-text">Queue is empty.</div>'; queueList.innerHTML = '<div class="placeholder-text">Queue is empty.</div>';
return; return;
@ -78,7 +90,7 @@ export function initializeUIInteractions(player, api) {
</div> </div>
<div class="track-item-duration">${formatTime(track.duration)}</div> <div class="track-item-duration">${formatTime(track.duration)}</div>
<button class="queue-remove-btn" data-track-index="${index}" title="Remove from queue"> <button class="queue-remove-btn" data-track-index="${index}" title="Remove from queue">
${SVG_CLOSE} ${SVG_BIN}
</button> </button>
</div> </div>
`; `;
@ -128,14 +140,20 @@ export function initializeUIInteractions(player, api) {
// Make renderQueue available globally for other modules // Make renderQueue available globally for other modules
window.renderQueueFunction = renderQueue; window.renderQueueFunction = renderQueue;
// Search tabs // Search and Library tabs
document.querySelectorAll('.search-tab').forEach(tab => { document.querySelectorAll('.search-tab').forEach(tab => {
tab.addEventListener('click', () => { tab.addEventListener('click', () => {
document.querySelectorAll('.search-tab').forEach(t => t.classList.remove('active')); const page = tab.closest('.page');
document.querySelectorAll('.search-tab-content').forEach(c => c.classList.remove('active')); if (!page) return;
page.querySelectorAll('.search-tab').forEach(t => t.classList.remove('active'));
page.querySelectorAll('.search-tab-content').forEach(c => c.classList.remove('active'));
tab.classList.add('active'); tab.classList.add('active');
document.getElementById(`search-tab-${tab.dataset.tab}`).classList.add('active');
const prefix = page.id === 'page-library' ? 'library-tab-' : 'search-tab-';
const contentId = `${prefix}${tab.dataset.tab}`;
document.getElementById(contentId)?.classList.add('active');
}); });
}); });
} }

248
js/ui.js
View file

@ -1,6 +1,7 @@
//js/ui.js //js/ui.js
import { SVG_PLAY, SVG_DOWNLOAD, SVG_MENU, formatTime, createPlaceholder, trackDataStore, hasExplicitContent, getTrackArtists, getTrackTitle, calculateTotalDuration, formatDuration } from './utils.js'; import { SVG_PLAY, SVG_DOWNLOAD, SVG_MENU, SVG_HEART, formatTime, createPlaceholder, trackDataStore, hasExplicitContent, getTrackArtists, getTrackTitle, calculateTotalDuration, formatDuration } from './utils.js';
import { recentActivityManager, backgroundSettings, trackListSettings } from './storage.js'; import { recentActivityManager, backgroundSettings, trackListSettings } from './storage.js';
import { db } from './db.js';
export class UIRenderer { export class UIRenderer {
constructor(api, player) { constructor(api, player) {
@ -10,9 +11,38 @@ export class UIRenderer {
this.searchAbortController = null; this.searchAbortController = null;
} }
// Helper for Heart Icon
createHeartIcon(filled = false) {
if (filled) {
return SVG_HEART.replace('class="heart-icon"', 'class="heart-icon filled"');
}
return SVG_HEART;
}
async updateLikeState(element, type, id) {
const isLiked = await db.isFavorite(type, id);
const btn = element.querySelector('.like-btn');
if (btn) {
btn.innerHTML = this.createHeartIcon(isLiked);
btn.classList.toggle('active', isLiked);
btn.title = isLiked ? 'Remove from Library' : 'Add to Library';
}
}
setCurrentTrack(track) { setCurrentTrack(track) {
this.currentTrack = track; this.currentTrack = track;
this.updateGlobalTheme(); this.updateGlobalTheme();
const likeBtn = document.getElementById('now-playing-like-btn');
if (likeBtn) {
if (track) {
likeBtn.style.display = 'flex';
// Use the centralized update logic if possible, or manual here
this.updateLikeState(likeBtn.parentElement, 'track', track.id);
} else {
likeBtn.style.display = 'none';
}
}
} }
updateGlobalTheme() { updateGlobalTheme() {
@ -86,6 +116,9 @@ export class UIRenderer {
const actionsHTML = ` const actionsHTML = `
<div class="track-actions-inline"> <div class="track-actions-inline">
<button class="track-action-btn like-btn" data-action="toggle-like" title="Add to Library">
${this.createHeartIcon(false)}
</button>
<button class="track-action-btn" data-action="play-next" title="Play Next"> <button class="track-action-btn" data-action="play-next" title="Play Next">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M2 6h6" /> <path d="M2 6h6" />
@ -154,38 +187,47 @@ export class UIRenderer {
} }
return ` return `
<a href="#album/${album.id}" class="card"> <div class="card" data-album-id="${album.id}" data-href="#album/${album.id}" style="cursor: pointer;">
<div class="card-image-wrapper"> <div class="card-image-wrapper">
<img src="${this.api.getCoverUrl(album.cover, '320')}" alt="${album.title}" class="card-image" loading="lazy"> <img src="${this.api.getCoverUrl(album.cover, '320')}" alt="${album.title}" class="card-image" loading="lazy">
<button class="like-btn card-like-btn" data-action="toggle-like" data-type="album" title="Add to Library">
${this.createHeartIcon(false)}
</button>
</div> </div>
<h3 class="card-title">${album.title} ${explicitBadge}</h3> <h3 class="card-title">${album.title} ${explicitBadge}</h3>
<p class="card-subtitle">${album.artist?.name ?? ''}</p> <p class="card-subtitle">${album.artist?.name ?? ''}</p>
<p class="card-subtitle">${yearDisplay}${typeLabel}</p> <p class="card-subtitle">${yearDisplay}${typeLabel}</p>
</a> </div>
`; `;
} }
createPlaylistCardHTML(playlist) { createPlaylistCardHTML(playlist) {
const imageId = playlist.squareImage || playlist.image || playlist.uuid; // Fallback or use a specific cover getter if needed const imageId = playlist.squareImage || playlist.image || playlist.uuid; // Fallback or use a specific cover getter if needed
return ` return `
<a href="#playlist/${playlist.uuid}" class="card"> <div class="card" data-playlist-id="${playlist.uuid}" data-href="#playlist/${playlist.uuid}" style="cursor: pointer;">
<div class="card-image-wrapper"> <div class="card-image-wrapper">
<img src="${this.api.getCoverUrl(imageId, '320')}" alt="${playlist.title}" class="card-image" loading="lazy"> <img src="${this.api.getCoverUrl(imageId, '320')}" alt="${playlist.title}" class="card-image" loading="lazy">
<button class="like-btn card-like-btn" data-action="toggle-like" data-type="playlist" title="Add to Library">
${this.createHeartIcon(false)}
</button>
</div> </div>
<h3 class="card-title">${playlist.title}</h3> <h3 class="card-title">${playlist.title}</h3>
<p class="card-subtitle">${playlist.numberOfTracks || 0} tracks</p> <p class="card-subtitle">${playlist.numberOfTracks || 0} tracks</p>
</a> </div>
`; `;
} }
createArtistCardHTML(artist) { createArtistCardHTML(artist) {
return ` return `
<a href="#artist/${artist.id}" class="card artist"> <div class="card artist" data-artist-id="${artist.id}" data-href="#artist/${artist.id}" style="cursor: pointer;">
<div class="card-image-wrapper"> <div class="card-image-wrapper">
<img src="${this.api.getArtistPictureUrl(artist.picture, '320')}" alt="${artist.name}" class="card-image" loading="lazy"> <img src="${this.api.getArtistPictureUrl(artist.picture, '320')}" alt="${artist.name}" class="card-image" loading="lazy">
<button class="like-btn card-like-btn" data-action="toggle-like" data-type="artist" title="Add to Library">
${this.createHeartIcon(false)}
</button>
</div> </div>
<h3 class="card-title">${artist.name}</h3> <h3 class="card-title">${artist.name}</h3>
</a> </div>
`; `;
} }
@ -242,7 +284,11 @@ export class UIRenderer {
tracks.forEach(track => { tracks.forEach(track => {
const element = container.querySelector(`[data-track-id="${track.id}"]`); const element = container.querySelector(`[data-track-id="${track.id}"]`);
if (element) trackDataStore.set(element, track); if (element) {
trackDataStore.set(element, track);
// Async update for like button
this.updateLikeState(element, 'track', track.id);
}
}); });
} }
@ -405,6 +451,65 @@ export class UIRenderer {
} }
} }
async renderLibraryPage() {
this.showPage('library');
const playlistsContainer = document.getElementById('library-playlists-container');
const tracksContainer = document.getElementById('library-tracks-container');
const albumsContainer = document.getElementById('library-albums-container');
const artistsContainer = document.getElementById('library-artists-container');
// Render Favorites
const likedPlaylists = await db.getFavorites('playlist');
if (likedPlaylists.length) {
playlistsContainer.innerHTML = likedPlaylists.map(p => this.createPlaylistCardHTML(p)).join('');
likedPlaylists.forEach(playlist => {
const el = playlistsContainer.querySelector(`[data-playlist-id="${playlist.uuid}"]`);
if (el) {
trackDataStore.set(el, playlist);
this.updateLikeState(el, 'playlist', playlist.uuid);
}
});
} else {
playlistsContainer.innerHTML = createPlaceholder('No liked playlists yet.');
}
const likedTracks = await db.getFavorites('track');
if (likedTracks.length) {
this.renderListWithTracks(tracksContainer, likedTracks, true);
} else {
tracksContainer.innerHTML = createPlaceholder('No liked songs yet.');
}
const likedAlbums = await db.getFavorites('album');
if (likedAlbums.length) {
albumsContainer.innerHTML = likedAlbums.map(a => this.createAlbumCardHTML(a)).join('');
likedAlbums.forEach(album => {
const el = albumsContainer.querySelector(`[data-album-id="${album.id}"]`);
if (el) {
trackDataStore.set(el, album);
this.updateLikeState(el, 'album', album.id);
}
});
} else {
albumsContainer.innerHTML = createPlaceholder('No liked albums yet.');
}
const likedArtists = await db.getFavorites('artist');
if (likedArtists.length) {
artistsContainer.innerHTML = likedArtists.map(a => this.createArtistCardHTML(a)).join('');
likedArtists.forEach(artist => {
const el = artistsContainer.querySelector(`[data-artist-id="${artist.id}"]`);
if (el) {
trackDataStore.set(el, artist);
this.updateLikeState(el, 'artist', artist.id);
}
});
} else {
artistsContainer.innerHTML = createPlaceholder('No liked artists yet.');
}
}
async renderHomePage() { async renderHomePage() {
this.showPage('home'); this.showPage('home');
const recents = recentActivityManager.getRecents(); const recents = recentActivityManager.getRecents();
@ -413,18 +518,45 @@ export class UIRenderer {
const artistsContainer = document.getElementById('home-recent-artists'); const artistsContainer = document.getElementById('home-recent-artists');
const playlistsContainer = document.getElementById('home-recent-playlists'); const playlistsContainer = document.getElementById('home-recent-playlists');
albumsContainer.innerHTML = recents.albums.length if (recents.albums.length) {
? recents.albums.map(album => this.createAlbumCardHTML(album)).join('') albumsContainer.innerHTML = recents.albums.map(album => this.createAlbumCardHTML(album)).join('');
: createPlaceholder("You haven't viewed any albums yet. Search for music to get started!"); recents.albums.forEach(album => {
const el = albumsContainer.querySelector(`[data-album-id="${album.id}"]`);
if (el) {
trackDataStore.set(el, album);
this.updateLikeState(el, 'album', album.id);
}
});
} else {
albumsContainer.innerHTML = createPlaceholder("You haven't viewed any albums yet. Search for music to get started!");
}
artistsContainer.innerHTML = recents.artists.length if (recents.artists.length) {
? recents.artists.map(artist => this.createArtistCardHTML(artist)).join('') artistsContainer.innerHTML = recents.artists.map(artist => this.createArtistCardHTML(artist)).join('');
: createPlaceholder("You haven't viewed any artists yet. Search for music to get started!"); recents.artists.forEach(artist => {
const el = artistsContainer.querySelector(`[data-artist-id="${artist.id}"]`);
if (el) {
trackDataStore.set(el, artist);
this.updateLikeState(el, 'artist', artist.id);
}
});
} else {
artistsContainer.innerHTML = createPlaceholder("You haven't viewed any artists yet. Search for music to get started!");
}
if (playlistsContainer) { if (playlistsContainer) {
playlistsContainer.innerHTML = recents.playlists && recents.playlists.length if (recents.playlists && recents.playlists.length) {
? recents.playlists.map(playlist => this.createPlaylistCardHTML(playlist)).join('') playlistsContainer.innerHTML = recents.playlists.map(playlist => this.createPlaylistCardHTML(playlist)).join('');
: createPlaceholder("You haven't viewed any playlists yet. Search for music to get started!"); recents.playlists.forEach(playlist => {
const el = playlistsContainer.querySelector(`[data-playlist-id="${playlist.uuid}"]`);
if (el) {
trackDataStore.set(el, playlist);
this.updateLikeState(el, 'playlist', playlist.uuid);
}
});
} else {
playlistsContainer.innerHTML = createPlaceholder("You haven't viewed any playlists yet. Search for music to get started!");
}
} }
} }
@ -498,14 +630,38 @@ export class UIRenderer {
? finalArtists.map(artist => this.createArtistCardHTML(artist)).join('') ? finalArtists.map(artist => this.createArtistCardHTML(artist)).join('')
: createPlaceholder('No artists found.'); : createPlaceholder('No artists found.');
finalArtists.forEach(artist => {
const el = artistsContainer.querySelector(`[data-artist-id="${artist.id}"]`);
if (el) {
trackDataStore.set(el, artist);
this.updateLikeState(el, 'artist', artist.id);
}
});
albumsContainer.innerHTML = finalAlbums.length albumsContainer.innerHTML = finalAlbums.length
? finalAlbums.map(album => this.createAlbumCardHTML(album)).join('') ? finalAlbums.map(album => this.createAlbumCardHTML(album)).join('')
: createPlaceholder('No albums found.'); : createPlaceholder('No albums found.');
finalAlbums.forEach(album => {
const el = albumsContainer.querySelector(`[data-album-id="${album.id}"]`);
if (el) {
trackDataStore.set(el, album);
this.updateLikeState(el, 'album', album.id);
}
});
playlistsContainer.innerHTML = finalPlaylists.length playlistsContainer.innerHTML = finalPlaylists.length
? finalPlaylists.map(playlist => this.createPlaylistCardHTML(playlist)).join('') ? finalPlaylists.map(playlist => this.createPlaylistCardHTML(playlist)).join('')
: createPlaceholder('No playlists found.'); : createPlaceholder('No playlists found.');
finalPlaylists.forEach(playlist => {
const el = playlistsContainer.querySelector(`[data-playlist-id="${playlist.uuid}"]`);
if (el) {
trackDataStore.set(el, playlist);
this.updateLikeState(el, 'playlist', playlist.uuid);
}
});
} catch (error) { } catch (error) {
if (error.name === 'AbortError') return; if (error.name === 'AbortError') return;
console.error("Search failed:", error); console.error("Search failed:", error);
@ -603,6 +759,14 @@ export class UIRenderer {
this.renderListWithTracks(tracklistContainer, tracks, false); this.renderListWithTracks(tracklistContainer, tracks, false);
recentActivityManager.addAlbum(album); recentActivityManager.addAlbum(album);
// Update header like button
const albumLikeBtn = document.getElementById('like-album-btn');
if (albumLikeBtn) {
const isLiked = await db.isFavorite('album', album.id);
albumLikeBtn.innerHTML = this.createHeartIcon(isLiked);
albumLikeBtn.classList.toggle('active', isLiked);
}
document.title = `${album.title} - ${album.artist.name} - Monochrome`; document.title = `${album.title} - ${album.artist.name} - Monochrome`;
@ -649,6 +813,14 @@ export class UIRenderer {
</div> </div>
`; `;
document.getElementById('page-album').appendChild(section); document.getElementById('page-album').appendChild(section);
filtered.forEach(a => {
const el = section.querySelector(`[data-album-id="${a.id}"]`);
if (el) {
trackDataStore.set(el, a);
this.updateLikeState(el, 'album', a.id);
}
});
}; };
renderSection(`More albums from ${album.artist.name}`, artistData.albums); renderSection(`More albums from ${album.artist.name}`, artistData.albums);
@ -667,7 +839,6 @@ export class UIRenderer {
async renderPlaylistPage(playlistId) { async renderPlaylistPage(playlistId) {
this.showPage('playlist'); this.showPage('playlist');
const imageEl = document.getElementById('playlist-detail-image'); const imageEl = document.getElementById('playlist-detail-image');
const titleEl = document.getElementById('playlist-detail-title'); const titleEl = document.getElementById('playlist-detail-title');
const metaEl = document.getElementById('playlist-detail-meta'); const metaEl = document.getElementById('playlist-detail-meta');
@ -696,7 +867,11 @@ export class UIRenderer {
const { playlist, tracks } = await this.api.getPlaylist(playlistId); const { playlist, tracks } = await this.api.getPlaylist(playlistId);
const imageId = playlist.squareImage || playlist.image; const imageId = playlist.squareImage || playlist.image;
imageEl.src = this.api.getCoverUrl(imageId, '1080'); if (imageId) {
imageEl.src = this.api.getCoverUrl(imageId, '1080');
} else {
imageEl.src = 'assets/appicon.png';
}
imageEl.style.backgroundColor = ''; imageEl.style.backgroundColor = '';
titleEl.textContent = playlist.title; titleEl.textContent = playlist.title;
@ -716,8 +891,22 @@ export class UIRenderer {
`; `;
this.renderListWithTracks(tracklistContainer, tracks, true); this.renderListWithTracks(tracklistContainer, tracks, true);
recentActivityManager.addPlaylist(playlist);
// Update header like button
const playlistLikeBtn = document.getElementById('like-playlist-btn');
if (playlistLikeBtn) {
const isLiked = await db.isFavorite('playlist', playlist.uuid);
playlistLikeBtn.innerHTML = this.createHeartIcon(isLiked);
playlistLikeBtn.classList.toggle('active', isLiked);
}
// Show/hide Delete button
const deleteBtn = document.getElementById('delete-playlist-btn');
if (deleteBtn) {
deleteBtn.style.display = 'none';
}
recentActivityManager.addPlaylist(playlist);
document.title = `${playlist.title || 'Artist Mix'} - Monochrome`; document.title = `${playlist.title || 'Artist Mix'} - Monochrome`;
} catch (error) { } catch (error) {
console.error("Failed to load playlist:", error); console.error("Failed to load playlist:", error);
@ -768,6 +957,17 @@ export class UIRenderer {
this.renderListWithTracks(tracksContainer, artist.tracks, true); this.renderListWithTracks(tracksContainer, artist.tracks, true);
// Update header like button
const artistLikeBtn = document.getElementById('like-artist-btn');
if (artistLikeBtn) {
const isLiked = await db.isFavorite('artist', artist.id);
artistLikeBtn.innerHTML = this.createHeartIcon(isLiked);
artistLikeBtn.classList.toggle('active', isLiked);
}
albumsContainer.innerHTML = artist.albums.map(album =>
this.createAlbumCardHTML(album)
).join('');
// Render Albums // Render Albums
albumsContainer.innerHTML = artist.albums.length albumsContainer.innerHTML = artist.albums.length
? artist.albums.map(album => this.createAlbumCardHTML(album)).join('') ? artist.albums.map(album => this.createAlbumCardHTML(album)).join('')
@ -783,6 +983,14 @@ export class UIRenderer {
} }
} }
artist.albums.forEach(album => {
const el = albumsContainer.querySelector(`[data-album-id="${album.id}"]`);
if (el) {
trackDataStore.set(el, album);
this.updateLikeState(el, 'album', album.id);
}
});
recentActivityManager.addArtist(artist); recentActivityManager.addArtist(artist);
document.title = `${artist.name} - Monochrome`; document.title = `${artist.name} - Monochrome`;

View file

@ -32,7 +32,9 @@ export const SVG_VOLUME = '<svg xmlns="http://www.w3.org/2000/svg" width="20" he
export const SVG_MUTE = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon><line x1="23" y1="9" x2="17" y2="15"></line><line x1="17" y1="9" x2="23" y2="15"></line></svg>'; export const SVG_MUTE = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon><line x1="23" y1="9" x2="17" y2="15"></line><line x1="17" y1="9" x2="23" y2="15"></line></svg>';
export const SVG_DOWNLOAD = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>'; export const SVG_DOWNLOAD = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>';
export const SVG_MENU = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="1"></circle><circle cx="12" cy="5" r="1"></circle><circle cx="12" cy="19" r="1"></circle></svg>'; export const SVG_MENU = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="1"></circle><circle cx="12" cy="5" r="1"></circle><circle cx="12" cy="19" r="1"></circle></svg>';
export const SVG_HEART = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="heart-icon"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path></svg>';
export const SVG_CLOSE = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>'; export const SVG_CLOSE = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>';
export const SVG_BIN = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path><line x1="10" y1="11" x2="10" y2="17"></line><line x1="14" y1="11" x2="14" y2="17"></line></svg>';
export const formatTime = (seconds) => { export const formatTime = (seconds) => {
if (isNaN(seconds)) return '0:00'; if (isNaN(seconds)) return '0:00';

View file

@ -528,6 +528,43 @@ body.has-page-background .track-item:hover {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.card-like-btn {
position: absolute;
top: 0.5rem;
right: 0.5rem;
background: rgba(0, 0, 0, 0.25) !important;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border-radius: 50% !important;
width: 32px !important;
height: 32px !important;
padding: 0 !important;
display: flex !important;
align-items: center;
justify-content: center;
opacity: 0;
transform: scale(0.8);
transition: all 0.2s ease !important;
z-index: 10;
color: white !important;
border: none !important;
}
.card:hover .card-like-btn,
.card-like-btn.active {
opacity: 1;
transform: scale(1);
}
.card-like-btn:hover {
background: rgba(0, 0, 0, 0.7) !important;
transform: scale(1.1) !important;
}
.card-like-btn.active {
color: #ef4444 !important;
}
.card-image { .card-image {
width: 100%; width: 100%;
aspect-ratio: 1/1; aspect-ratio: 1/1;
@ -562,6 +599,24 @@ body.has-page-background .track-item:hover {
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.heart-icon {
transition: transform 0.2s ease, color 0.2s ease;
}
.heart-icon.filled {
color: #ef4444;
fill: #ef4444;
}
.track-item:hover .like-btn {
opacity: 1;
}
.like-btn.active .heart-icon {
color: #ef4444;
fill: #ef4444;
}
.explicit-badge { .explicit-badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@ -724,10 +779,14 @@ body.has-page-background .track-item:hover {
.track-actions-inline { .track-actions-inline {
display: none; /* Controlled by data-track-actions-mode */ display: none; /* Controlled by data-track-actions-mode */
gap: 0.25rem; gap: 0.25rem;
opacity: 0.2; /* Barely visible instead of invisible */ opacity: 0.2;
transition: opacity var(--transition); transition: opacity var(--transition);
} }
.track-action-btn.active {
opacity: 1;
}
[data-track-actions-mode="inline"] .track-actions-inline { [data-track-actions-mode="inline"] .track-actions-inline {
display: flex; display: flex;
} }
@ -1105,7 +1164,7 @@ input:checked + .slider::before {
.player-controls .buttons button#repeat-btn.repeat-one::after { .player-controls .buttons button#repeat-btn.repeat-one::after {
content: '1'; content: '1';
position: absolute; position: absolute;
font-size: 0.6rem; font-size: 0.5rem;
font-weight: bold; font-weight: bold;
} }
@ -1372,12 +1431,10 @@ input:checked + .slider::before {
text-align: center; text-align: center;
z-index: 1; z-index: 1;
max-width: 90%; max-width: 90%;
background: color-mix(in srgb, var(--card), transparent 25%); background: color-mix(in srgb, var(--card), transparent 80%);
padding: 1.5rem 2rem; padding: 1.5rem 2rem;
border-radius: var(--radius); border-radius: var(--radius);
backdrop-filter: blur(12px); border: 1px solid color-mix(in srgb, var(--card), transparent 70%);
-webkit-backdrop-filter: blur(12px);
border: 1px solid color-mix(in srgb, var(--border), transparent 50%);
} }
#fullscreen-track-title { #fullscreen-track-title {
@ -1390,14 +1447,14 @@ input:checked + .slider::before {
#fullscreen-track-artist { #fullscreen-track-artist {
font-size: 1.25rem; font-size: 1.25rem;
color: var(--muted-foreground); color: var(--primary);
font-weight: 500; font-weight: 500;
} }
#fullscreen-next-track { #fullscreen-next-track {
margin-top: 1.5rem; margin-top: 1.5rem;
font-size: 0.9rem; font-size: 0.9rem;
color: var(--muted-foreground); color: var(--primary);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.2rem; gap: 0.2rem;
@ -1463,7 +1520,7 @@ input:checked + .slider::before {
margin: 0; margin: 0;
} }
#queue-modal-header button { #queue-modal-header #close-queue-btn {
background: transparent; background: transparent;
border: none; border: none;
color: var(--muted-foreground); color: var(--muted-foreground);
@ -1478,7 +1535,12 @@ input:checked + .slider::before {
transition: all var(--transition); transition: all var(--transition);
} }
#queue-modal-header button:hover { #queue-modal-header #clear-queue-btn {
background-color: transparent;
}
#queue-modal-header #clear-queue-btn:hover,
#queue-modal-header #close-queue-btn:hover {
background-color: var(--secondary); background-color: var(--secondary);
color: var(--foreground); color: var(--foreground);
} }
@ -1562,7 +1624,6 @@ input:checked + .slider::before {
.placeholder-text { .placeholder-text {
padding: 2rem 1rem; padding: 2rem 1rem;
color: var(--muted-foreground); color: var(--muted-foreground);
text-align: center;
} }
.placeholder-text.loading { .placeholder-text.loading {