From e9c9021db0bc770f44f861bd0b00321e857b33b8 Mon Sep 17 00:00:00 2001 From: Broque Thomas Date: Fri, 12 Dec 2025 23:02:28 -0800 Subject: [PATCH] Add animated mosaic backgrounds to wishlist categories Introduces a dynamic mosaic grid background with scrolling album covers for the wishlist category cards in the overview modal. Adds supporting JavaScript functions to extract unique cover images and generate the mosaic HTML, and updates CSS to style and animate the mosaic, including fallbacks and hover effects for improved visual appeal. --- webui/static/script.js | 132 +++++++++++++++++++++++++++++++++++++--- webui/static/style.css | 134 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 255 insertions(+), 11 deletions(-) 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 {