diff --git a/index.html b/index.html index 9a5b593..e8976ab 100644 --- a/index.html +++ b/index.html @@ -22,6 +22,7 @@
+
+

Your Library

+
+ + + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -173,6 +205,9 @@ +
@@ -190,9 +225,13 @@ + + @@ -215,6 +254,9 @@ + @@ -370,6 +412,17 @@ +
+
+ Backup & Restore + Export or import your library and playlists as JSON +
+
+ + + +
+
@@ -466,6 +519,8 @@
+
${formatTime(track.duration)}
`; @@ -128,14 +140,20 @@ export function initializeUIInteractions(player, api) { // Make renderQueue available globally for other modules window.renderQueueFunction = renderQueue; - // Search tabs + // Search and Library tabs document.querySelectorAll('.search-tab').forEach(tab => { tab.addEventListener('click', () => { - document.querySelectorAll('.search-tab').forEach(t => t.classList.remove('active')); - document.querySelectorAll('.search-tab-content').forEach(c => c.classList.remove('active')); + const page = tab.closest('.page'); + 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'); - 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'); }); }); } diff --git a/js/ui.js b/js/ui.js index 5b8cf94..55ab12f 100644 --- a/js/ui.js +++ b/js/ui.js @@ -1,6 +1,7 @@ //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'; export class UIRenderer { constructor(api, player) { @@ -10,9 +11,38 @@ export class UIRenderer { 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) { 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() { @@ -86,6 +116,9 @@ export class UIRenderer { const actionsHTML = `
+

${album.title} ${explicitBadge}

${album.artist?.name ?? ''}

${yearDisplay}${typeLabel}

-
+ `; } createPlaylistCardHTML(playlist) { const imageId = playlist.squareImage || playlist.image || playlist.uuid; // Fallback or use a specific cover getter if needed return ` - +
${playlist.title} +

${playlist.title}

${playlist.numberOfTracks || 0} tracks

-
+
`; } createArtistCardHTML(artist) { return ` - +
${artist.name} +

${artist.name}

-
+
`; } @@ -242,7 +284,11 @@ export class UIRenderer { tracks.forEach(track => { 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() { this.showPage('home'); const recents = recentActivityManager.getRecents(); @@ -413,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!"); + } } } @@ -498,14 +630,38 @@ export class UIRenderer { ? finalArtists.map(artist => this.createArtistCardHTML(artist)).join('') : 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 ? finalAlbums.map(album => this.createAlbumCardHTML(album)).join('') : 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 ? finalPlaylists.map(playlist => this.createPlaylistCardHTML(playlist)).join('') : 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) { if (error.name === 'AbortError') return; console.error("Search failed:", error); @@ -603,6 +759,14 @@ export class UIRenderer { this.renderListWithTracks(tracklistContainer, tracks, false); 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`; @@ -649,6 +813,14 @@ export class UIRenderer { `; 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); @@ -667,7 +839,6 @@ export class UIRenderer { async renderPlaylistPage(playlistId) { this.showPage('playlist'); - const imageEl = document.getElementById('playlist-detail-image'); const titleEl = document.getElementById('playlist-detail-title'); const metaEl = document.getElementById('playlist-detail-meta'); @@ -696,7 +867,11 @@ export class UIRenderer { const { playlist, tracks } = await this.api.getPlaylist(playlistId); 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 = ''; titleEl.textContent = playlist.title; @@ -716,8 +891,22 @@ export class UIRenderer { `; 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`; } catch (error) { console.error("Failed to load playlist:", error); @@ -768,6 +957,17 @@ export class UIRenderer { 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 albumsContainer.innerHTML = artist.albums.length ? 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); document.title = `${artist.name} - Monochrome`; diff --git a/js/utils.js b/js/utils.js index 69c8b5b..b225985 100644 --- a/js/utils.js +++ b/js/utils.js @@ -32,7 +32,9 @@ export const SVG_VOLUME = ' { if (isNaN(seconds)) return '0:00'; diff --git a/styles.css b/styles.css index 03268b3..a93f018 100644 --- a/styles.css +++ b/styles.css @@ -528,6 +528,43 @@ body.has-page-background .track-item:hover { 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 { width: 100%; aspect-ratio: 1/1; @@ -562,6 +599,24 @@ body.has-page-background .track-item:hover { 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 { display: inline-flex; align-items: center; @@ -724,10 +779,14 @@ body.has-page-background .track-item:hover { .track-actions-inline { display: none; /* Controlled by data-track-actions-mode */ gap: 0.25rem; - opacity: 0.2; /* Barely visible instead of invisible */ + opacity: 0.2; transition: opacity var(--transition); } +.track-action-btn.active { + opacity: 1; +} + [data-track-actions-mode="inline"] .track-actions-inline { display: flex; } @@ -1105,7 +1164,7 @@ input:checked + .slider::before { .player-controls .buttons button#repeat-btn.repeat-one::after { content: '1'; position: absolute; - font-size: 0.6rem; + font-size: 0.5rem; font-weight: bold; } @@ -1372,12 +1431,10 @@ input:checked + .slider::before { text-align: center; z-index: 1; 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; border-radius: var(--radius); - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); - border: 1px solid color-mix(in srgb, var(--border), transparent 50%); + border: 1px solid color-mix(in srgb, var(--card), transparent 70%); } #fullscreen-track-title { @@ -1390,14 +1447,14 @@ input:checked + .slider::before { #fullscreen-track-artist { font-size: 1.25rem; - color: var(--muted-foreground); + color: var(--primary); font-weight: 500; } #fullscreen-next-track { margin-top: 1.5rem; font-size: 0.9rem; - color: var(--muted-foreground); + color: var(--primary); display: flex; flex-direction: column; gap: 0.2rem; @@ -1463,7 +1520,7 @@ input:checked + .slider::before { margin: 0; } -#queue-modal-header button { +#queue-modal-header #close-queue-btn { background: transparent; border: none; color: var(--muted-foreground); @@ -1478,7 +1535,12 @@ input:checked + .slider::before { 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); color: var(--foreground); } @@ -1562,7 +1624,6 @@ input:checked + .slider::before { .placeholder-text { padding: 2rem 1rem; color: var(--muted-foreground); - text-align: center; } .placeholder-text.loading {