diff --git a/webui/static/script.js b/webui/static/script.js index 86513f85..2f72fe28 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -5035,6 +5035,106 @@ async function closeDownloadMissingModal(playlistId) { } } +/** + * Extract unique album cover images from tracks + */ +function extractUniqueCoverImages(tracks, maxCovers = 20) { + const uniqueCovers = new Set(); + const covers = []; + + for (const track of tracks) { + if (covers.length >= maxCovers) break; + + let coverUrl = null; + let spotifyData = track.spotify_data; + + // Parse spotify_data if it's a string + if (typeof spotifyData === 'string') { + try { + spotifyData = JSON.parse(spotifyData); + } catch (e) { + continue; + } + } + + // Extract cover URL + coverUrl = spotifyData?.album?.images?.[0]?.url; + + // Add to list if unique and valid + if (coverUrl && !uniqueCovers.has(coverUrl)) { + uniqueCovers.add(coverUrl); + covers.push(coverUrl); + } + } + + return covers; +} + +/** + * Shuffle array using Fisher-Yates algorithm + */ +function shuffleArray(array) { + const shuffled = [...array]; + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; + } + return shuffled; +} + +/** + * Generate mosaic grid background HTML with continuous scrolling rows + */ +function generateMosaicBackground(coverUrls) { + // If less than 3 covers, use gradient fallback + if (!coverUrls || coverUrls.length < 3) { + return ` +
+
+ `; + } + + const rows = 4; + let mosaicHTML = '
'; + + // Calculate scroll speed based on number of images + // More images = longer duration to maintain consistent visual speed + // Minimum 40s to prevent scrolling too fast + const scrollSpeed = Math.max(40, coverUrls.length * 2); + + for (let row = 0; row < rows; row++) { + const isEvenRow = row % 2 === 0; + const direction = isEvenRow ? 'left' : 'right'; + + // Randomize order for each row + const shuffledCovers = shuffleArray(coverUrls); + + // Create row wrapper + mosaicHTML += `
`; + mosaicHTML += `
`; + + // Generate tiles - duplicate 3 times for smooth infinite scroll + for (let duplicate = 0; duplicate < 3; duplicate++) { + for (let i = 0; i < shuffledCovers.length; i++) { + const coverUrl = shuffledCovers[i]; + mosaicHTML += ` +
+
+
+ `; + } + } + + mosaicHTML += '
'; // Close row + mosaicHTML += '
'; // Close wrapper + } + + mosaicHTML += '
'; + mosaicHTML += '
'; // Dark overlay for readability + + return mosaicHTML; +} + /** * Open wishlist overview modal showing category breakdown * This is the NEW entry point for wishlist from dashboard @@ -5059,6 +5159,16 @@ async function openWishlistOverviewModal() { return; } + // Fetch album covers for mosaic backgrounds + const albumCoversPromise = fetch('/api/wishlist/tracks?category=albums').then(r => r.json()); + const singleCoversPromise = fetch('/api/wishlist/tracks?category=singles').then(r => r.json()); + + const [albumTracksData, singleTracksData] = await Promise.all([albumCoversPromise, singleCoversPromise]); + + // Extract unique album covers (max 20 per category) + const albumCovers = extractUniqueCoverImages(albumTracksData.tracks || [], 20); + const singleCovers = extractUniqueCoverImages(singleTracksData.tracks || [], 20); + // Create modal if it doesn't exist let modal = document.getElementById('wishlist-overview-modal'); if (!modal) { @@ -5095,18 +5205,24 @@ async function openWishlistOverviewModal() {
-
💿
-
Albums / EPs
-
${albums} tracks
- ${currentCycle === 'albums' ? '
Next in Queue
' : ''} + ${generateMosaicBackground(albumCovers)} +
+
💿
+
Albums / EPs
+
${albums} tracks
+ ${currentCycle === 'albums' ? '
Next in Queue
' : ''} +
-
🎵
-
Singles
-
${singles} tracks
- ${currentCycle === 'singles' ? '
Next in Queue
' : ''} + ${generateMosaicBackground(singleCovers)} +
+
🎵
+
Singles
+
${singles} tracks
+ ${currentCycle === 'singles' ? '
Next in Queue
' : ''} +
diff --git a/webui/static/style.css b/webui/static/style.css index cf52dbda..13e8dd1e 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -7627,6 +7627,122 @@ body { transition: all 0.3s ease; position: relative; overflow: hidden; + min-height: 250px; + display: flex; + align-items: center; + justify-content: center; +} + +/* Mosaic Background with Scrolling Rows */ +.wishlist-mosaic-background { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + z-index: 0; +} + +.wishlist-mosaic-row-wrapper { + flex: 1; + overflow: hidden; + position: relative; +} + +.wishlist-mosaic-row { + display: flex; + width: fit-content; + height: 100%; + opacity: 0; + animation: fadeInRow 1s ease-out forwards, infiniteScroll var(--speed, 30s) linear infinite; +} + +.wishlist-mosaic-row.scroll-right { + animation-direction: normal, reverse; +} + +.wishlist-mosaic-tile { + width: 80px; + height: 100%; + flex-shrink: 0; + overflow: hidden; +} + +.wishlist-mosaic-image { + width: 100%; + height: 100%; + background-size: cover; + background-position: center; + transition: transform 0.5s ease; +} + +/* Fallback gradient for small collections */ +.wishlist-mosaic-fallback { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: linear-gradient(135deg, + rgba(29, 185, 84, 0.1) 0%, + rgba(138, 43, 226, 0.1) 50%, + rgba(255, 20, 147, 0.1) 100%); + background-size: 200% 200%; + animation: gradientShift 8s ease infinite; + z-index: 0; +} + +@keyframes gradientShift { + 0%, 100% { + background-position: 0% 50%; + } + 50% { + background-position: 100% 50%; + } +} + +/* Overlay for text readability */ +.wishlist-mosaic-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: linear-gradient(135deg, + rgba(0, 0, 0, 0.85) 0%, + rgba(0, 0, 0, 0.75) 100%); + z-index: 1; + pointer-events: none; +} + +/* Content layer above mosaic */ +.wishlist-category-content { + position: relative; + z-index: 2; + width: 100%; +} + +/* Fade in rows initially */ +@keyframes fadeInRow { + 0% { + opacity: 0; + } + 100% { + opacity: 0.7; + } +} + +/* Infinite scrolling animation */ +/* Content is duplicated 3x, animation moves by -33.333% (one set) for seamless loop */ +@keyframes infiniteScroll { + 0% { + transform: translateX(0); + } + 100% { + transform: translateX(-33.333%); + } } .wishlist-category-card::before { @@ -7646,14 +7762,21 @@ body { } .wishlist-category-card:hover { - background: linear-gradient(135deg, - rgba(29, 185, 84, 0.1) 0%, - rgba(18, 18, 18, 0.98) 100%); border-color: rgba(29, 185, 84, 0.3); transform: translateY(-4px); box-shadow: 0 8px 24px rgba(29, 185, 84, 0.2); } +.wishlist-category-card:hover .wishlist-mosaic-image { + transform: scale(1.1); +} + +.wishlist-category-card:hover .wishlist-mosaic-overlay { + background: linear-gradient(135deg, + rgba(29, 185, 84, 0.2) 0%, + rgba(0, 0, 0, 0.8) 100%); +} + .wishlist-category-card.next-in-queue { border-color: rgba(29, 185, 84, 0.4); background: linear-gradient(135deg, @@ -8101,6 +8224,11 @@ body { .wishlist-category-card { padding: 24px 20px; + min-height: 200px; + } + + .wishlist-mosaic-tile { + width: 60px; /* Smaller tiles on mobile */ } .wishlist-category-icon {