Fix heart button interactions on cards and reorder library tabs

- Fix: Card heart buttons didn't work because of button nested in anchor. Changed cards to div with data-href.\n- Fix: Home page and Artist page cards didn't have data bound or like state initialized.\n- Feature: Move Playlists to the last position in Library tabs.
This commit is contained in:
Julien Maille 2025-12-26 11:23:38 +01:00 committed by Julien Maille
parent f4f6a1941c
commit cdffe88eba
6 changed files with 209 additions and 73 deletions

View file

@ -22,6 +22,7 @@
<audio id="audio-player"></audio>
<div id="context-menu">
<ul>
<li data-action="toggle-like">Like</li>
<li data-action="play-next">Play Next</li>
<li data-action="add-to-queue">Add to Queue</li>
<li data-action="download">Download</li>
@ -168,15 +169,12 @@
<div id="page-library" class="page">
<h2 class="section-title">Your Library</h2>
<div class="search-tabs">
<button class="search-tab active" data-tab="playlists">Playlists</button>
<button class="search-tab" data-tab="tracks">Liked Songs</button>
<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-playlists">
<div class="card-grid" id="library-playlists-container"></div>
</div>
<div class="search-tab-content" id="library-tab-tracks">
<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">
@ -185,6 +183,9 @@
<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">
@ -515,6 +516,8 @@
</div>
</div>
<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">
<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>

View file

@ -200,13 +200,18 @@ document.addEventListener('DOMContentLoaded', async () => {
initializeSettings(scrobbler, player, api, ui);
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);
initializeKeyboardShortcuts(player, audioPlayer, lyricsPanel);
const castBtn = document.getElementById('cast-btn');
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 () => {
if (!player.currentTrack) {
alert('No track is currently playing');
@ -284,7 +289,7 @@ document.addEventListener('DOMContentLoaded', async () => {
document.getElementById('download-current-btn')?.addEventListener('click', () => {
if (player.currentTrack) {
handleTrackAction('download', player.currentTrack, player, api, lyricsManager);
handleTrackAction('download', player.currentTrack, player, api, lyricsManager, 'track', ui);
}
});

View file

@ -291,23 +291,95 @@ function initializeSmoothSliders(audioPlayer, player) {
});
}
export async function handleTrackAction(action, track, player, api, lyricsManager) {
if (!track) return;
export async function handleTrackAction(action, item, player, api, lyricsManager, type = 'track', ui = null) {
if (!item) return;
if (action === 'add-to-queue') {
player.addToQueue(track);
player.addToQueue(item);
renderQueue(player);
showNotification(`Added to queue: ${track.title}`);
showNotification(`Added to queue: ${item.title}`);
} else if (action === 'play-next') {
player.addNextToQueue(track);
player.addNextToQueue(item);
renderQueue(player);
showNotification(`Playing next: ${track.title}`);
showNotification(`Playing next: ${item.title}`);
} 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'
? `.track-item[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;
mainContent.addEventListener('click', async e => {
@ -340,37 +412,7 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
}
if (item) {
if (action === 'add-to-queue' && type === 'track') {
player.addToQueue(item);
renderQueue(player);
showNotification(`Added to queue: ${item.title}`);
} else if (action === 'toggle-like') {
db.toggleFavorite(type, item).then(added => {
const heartIcon = actionBtn.querySelector('svg');
if (heartIcon) {
heartIcon.setAttribute('fill', added ? 'currentColor' : 'none');
heartIcon.classList.toggle('filled', added);
}
actionBtn.classList.toggle('active', added);
actionBtn.title = added ? 'Remove from Library' : 'Add to Library';
if (!added && window.location.hash === '#library' && itemElement) {
itemElement.remove();
const containerId = `library-${type}s-container`;
const container = document.getElementById(containerId);
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 (action === 'play-next' && type === 'track') {
player.addNextToQueue(item);
renderQueue(player);
showNotification(`Playing next: ${item.title}`);
} else if (action === 'download' && type === 'track') {
handleDownload(item, player, api);
}
await handleTrackAction(action, item, player, api, lyricsManager, type, ui);
}
return;
}
@ -382,6 +424,7 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
if (trackItem && !trackItem.dataset.queueIndex) {
contextTrack = trackDataStore.get(trackItem);
if (contextTrack) {
await updateContextMenuLikeState(contextMenu, contextTrack);
const rect = menuBtn.getBoundingClientRect();
positionMenu(contextMenu, rect.left, rect.bottom + 5, rect);
}
@ -404,15 +447,28 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
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');
if (trackItem && !trackItem.dataset.queueIndex) {
e.preventDefault();
contextTrack = trackDataStore.get(trackItem);
if (contextTrack) {
await updateContextMenuLikeState(contextMenu, contextTrack);
positionMenu(contextMenu, e.pageX, e.pageY);
}
}
@ -426,7 +482,7 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
e.stopPropagation();
const action = e.target.dataset.action;
if (action && contextTrack) {
await handleTrackAction(action, contextTrack, player, api, lyricsManager);
await handleTrackAction(action, contextTrack, player, api, lyricsManager, 'track', ui);
}
contextMenu.style.display = 'none';
});
@ -445,6 +501,16 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
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) {
@ -461,6 +527,14 @@ function formatTime(seconds) {
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) {
// Temporarily show to measure dimensions
menu.style.visibility = 'hidden';

View file

@ -1,5 +1,5 @@
//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 { db } from './db.js';
@ -13,11 +13,10 @@ export class UIRenderer {
// Helper for Heart Icon
createHeartIcon(filled = false) {
return `
<svg width="20" height="20" viewBox="0 0 24 24" fill="${filled ? 'currentColor' : 'none'}" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="heart-icon ${filled ? 'filled' : ''}">
<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>
`;
if (filled) {
return SVG_HEART.replace('class="heart-icon"', 'class="heart-icon filled"');
}
return SVG_HEART;
}
async updateLikeState(element, type, id) {
@ -33,6 +32,17 @@ export class UIRenderer {
setCurrentTrack(track) {
this.currentTrack = track;
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() {
@ -177,7 +187,7 @@ export class UIRenderer {
}
return `
<a href="#album/${album.id}" class="card" data-album-id="${album.id}">
<div class="card" data-album-id="${album.id}" data-href="#album/${album.id}" style="cursor: pointer;">
<div class="card-image-wrapper">
<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">
@ -187,14 +197,14 @@ export class UIRenderer {
<h3 class="card-title">${album.title} ${explicitBadge}</h3>
<p class="card-subtitle">${album.artist?.name ?? ''}</p>
<p class="card-subtitle">${yearDisplay}${typeLabel}</p>
</a>
</div>
`;
}
createPlaylistCardHTML(playlist) {
const imageId = playlist.squareImage || playlist.image || playlist.uuid; // Fallback or use a specific cover getter if needed
return `
<a href="#playlist/${playlist.uuid}" class="card" data-playlist-id="${playlist.uuid}">
<div class="card" data-playlist-id="${playlist.uuid}" data-href="#playlist/${playlist.uuid}" style="cursor: pointer;">
<div class="card-image-wrapper">
<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">
@ -203,13 +213,13 @@ export class UIRenderer {
</div>
<h3 class="card-title">${playlist.title}</h3>
<p class="card-subtitle">${playlist.numberOfTracks || 0} tracks</p>
</a>
</div>
`;
}
createArtistCardHTML(artist) {
return `
<a href="#artist/${artist.id}" class="card artist" data-artist-id="${artist.id}">
<div class="card artist" data-artist-id="${artist.id}" data-href="#artist/${artist.id}" style="cursor: pointer;">
<div class="card-image-wrapper">
<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">
@ -217,7 +227,7 @@ export class UIRenderer {
</button>
</div>
<h3 class="card-title">${artist.name}</h3>
</a>
</div>
`;
}
@ -508,18 +518,45 @@ export class UIRenderer {
const artistsContainer = document.getElementById('home-recent-artists');
const playlistsContainer = document.getElementById('home-recent-playlists');
albumsContainer.innerHTML = recents.albums.length
? recents.albums.map(album => this.createAlbumCardHTML(album)).join('')
: createPlaceholder("You haven't viewed any albums yet. Search for music to get started!");
if (recents.albums.length) {
albumsContainer.innerHTML = recents.albums.map(album => this.createAlbumCardHTML(album)).join('');
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
? recents.artists.map(artist => this.createArtistCardHTML(artist)).join('')
: createPlaceholder("You haven't viewed any artists yet. Search for music to get started!");
if (recents.artists.length) {
artistsContainer.innerHTML = recents.artists.map(artist => this.createArtistCardHTML(artist)).join('');
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) {
playlistsContainer.innerHTML = recents.playlists && recents.playlists.length
? recents.playlists.map(playlist => this.createPlaylistCardHTML(playlist)).join('')
: createPlaceholder("You haven't viewed any playlists yet. Search for music to get started!");
if (recents.playlists && recents.playlists.length) {
playlistsContainer.innerHTML = recents.playlists.map(playlist => this.createPlaylistCardHTML(playlist)).join('');
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!");
}
}
}
@ -776,6 +813,14 @@ export class UIRenderer {
</div>
`;
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);
@ -938,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);
document.title = `${artist.name} - Monochrome`;

View file

@ -32,6 +32,7 @@ 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_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_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 formatTime = (seconds) => {

View file

@ -532,7 +532,7 @@ body.has-page-background .track-item:hover {
position: absolute;
top: 0.5rem;
right: 0.5rem;
background: rgba(0, 0, 0, 0.5) !important;
background: rgba(0, 0, 0, 0.25) !important;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border-radius: 50% !important;
@ -547,7 +547,7 @@ body.has-page-background .track-item:hover {
transition: all 0.2s ease !important;
z-index: 10;
color: white !important;
border: 1px solid rgba(255, 255, 255, 0.1) !important;
border: none !important;
}
.card:hover .card-like-btn,
@ -779,7 +779,7 @@ body.has-page-background .track-item:hover {
.track-actions-inline {
display: none; /* Controlled by data-track-actions-mode */
gap: 0.25rem;
opacity: 0.5;
opacity: 0.2;
transition: opacity var(--transition);
}