From 16ad6184ed2c1792a6f597f16537faafe29a155a Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Thu, 16 Apr 2026 16:33:16 -0700 Subject: [PATCH] =?UTF-8?q?Wishlist=20Nebula=20enhancements=20=E2=80=94=20?= =?UTF-8?q?artist=20photos,=20art=20ring,=20animations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight visual upgrades to the wishlist nebula: 1. Watchlist artist photos used for orb images (cross-referenced via API) 2. Hover tooltip with artist name and track count 3. Pulse animation on orbs when their category is next in auto-processing 4. Album art ring — tiny covers orbit each orb in a slow spinning ring 5. Staggered entry animation — orbs fade in and float up on page load 6. Click artist name to navigate to Artists page with search pre-filled 7. Improved label styling with accent color hover + underline 8. Responsive art ring sizing per orb size class --- webui/static/script.js | 77 +++++++++++++++++++++++++++++++++++------- webui/static/style.css | 45 +++++++++++++++++++++++- 2 files changed, 108 insertions(+), 14 deletions(-) diff --git a/webui/static/script.js b/webui/static/script.js index 74ace3c7..3c1f3894 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -40490,13 +40490,22 @@ async function initializeWishlistPage() { const tracksSection = document.getElementById('wishlist-category-tracks'); const statsStrip = document.getElementById('wishlist-stats-strip'); - const [statsRes, cycleRes, albumRes, singleRes] = await Promise.all([ + const [statsRes, cycleRes, albumRes, singleRes, watchlistRes] = await Promise.all([ fetch('/api/wishlist/stats').then(r => r.json()), fetch('/api/wishlist/cycle').then(r => r.json()), fetch('/api/wishlist/tracks?category=albums').then(r => r.json()), fetch('/api/wishlist/tracks?category=singles').then(r => r.json()), + fetch('/api/watchlist/artists').then(r => r.json()).catch(() => ({ success: false })), ]); + // Build artist name → image URL map from watchlist + const _artistImageMap = new Map(); + if (watchlistRes.success && watchlistRes.artists) { + for (const wa of watchlistRes.artists) { + if (wa.artist_name && wa.image_url) _artistImageMap.set(wa.artist_name.toLowerCase(), wa.image_url); + } + } + const { singles = 0, albums = 0, total = 0 } = statsRes; const currentCycle = cycleRes.cycle || 'albums'; @@ -40524,7 +40533,7 @@ async function initializeWishlistPage() { if (tracksSection) tracksSection.style.display = 'none'; if (statsStrip) statsStrip.style.display = ''; - _renderWishlistNebula(albumRes.tracks || [], singleRes.tracks || []); + _renderWishlistNebula(albumRes.tracks || [], singleRes.tracks || [], _artistImageMap, currentCycle); startWishlistCountdownTimer(currentCycle, statsRes.next_run_in_seconds || 0); wishlistPageState.isInitialized = true; @@ -40538,9 +40547,10 @@ async function initializeWishlistPage() { WISHLIST NEBULA — Artist orbs with album/single satellites ═══════════════════════════════════════════════════════════════════ */ -function _renderWishlistNebula(albumTracks, singleTracks) { +function _renderWishlistNebula(albumTracks, singleTracks, artistImageMap, currentCycle) { const field = document.getElementById('wl-nebula-field'); if (!field) return; + artistImageMap = artistImageMap || new Map(); const artistMap = new Map(); function _parse(track, type) { @@ -40570,23 +40580,53 @@ function _renderWishlistNebula(albumTracks, singleTracks) { function _hue(n) { let h = 0; for (let i = 0; i < n.length; i++) h = n.charCodeAt(i) + ((h << 5) - h); return Math.abs(h) % 360; } let html = ''; - for (const [name, data] of sorted) { + sorted.forEach(([name, data], idx) => { const total = [...data.albums.values()].reduce((s, a) => s + a.tracks.length, 0) + data.singles.length; + const hasAlbums = data.albums.size > 0; const hue = _hue(name); const sz = total >= 10 ? 'orb-lg' : total >= 4 ? 'orb-md' : 'orb-sm'; - let img = ''; - for (const [, ad] of data.albums) { if (ad.image) { img = ad.image; break; } } + + // Enhancement 1: prefer watchlist artist photo over album cover + let img = artistImageMap.get(name.toLowerCase()) || ''; + if (!img) { for (const [, ad] of data.albums) { if (ad.image) { img = ad.image; break; } } } if (!img && data.singles.length) img = data.singles[0].image || ''; - html += `
`; - html += `
`; + // Enhancement 3: pulse if this artist has albums and current cycle is albums + const pulseClass = (hasAlbums && currentCycle === 'albums') ? ' orb-pulse' : ''; + + // Enhancement 7: staggered entry animation + const delay = Math.min(idx * 60, 800); + + html += `
`; + + // Enhancement 2: hover tooltip + html += `
${escapeHtml(name)}
${total} track${total !== 1 ? 's' : ''}
`; + + html += `
`; html += `
`; - html += img ? `` : `
${escapeHtml(name.substring(0, 2).toUpperCase())}
`; + html += img ? `` : `
${escapeHtml(name.substring(0, 2).toUpperCase())}
`; html += `
`; - html += `
`; - html += `
${escapeHtml(name)}
`; + + // Enhancement 5: album art ring (show up to 6 album covers around the orb) + const ringCovers = []; + for (const [, ad] of data.albums) { if (ad.image && ringCovers.length < 6) ringCovers.push(ad.image); } + for (const s of data.singles) { if (s.image && ringCovers.length < 6) ringCovers.push(s.image); } + if (ringCovers.length >= 3) { + html += `
`; + ringCovers.forEach((url, i) => { + const angle = (360 / ringCovers.length) * i; + html += ``; + }); + html += `
`; + } + + html += `
`; // /orb + + // Enhancement 8: clickable artist name → navigate to artist detail + html += `
${escapeHtml(name)}
`; html += `
${total} track${total !== 1 ? 's' : ''}
`; + // Expanded content html += `
`; if (data.albums.size > 0) { html += `
`; @@ -40610,11 +40650,22 @@ function _renderWishlistNebula(albumTracks, singleTracks) { } html += `
`; } - html += `
`; - } + html += `
`; // /expanded, /group + }); + field.innerHTML = html; } +// Enhancement 8: navigate to artist detail from wishlist +function _navigateToArtistFromWishlist(artistName) { + // Try to find the artist in the library DB by searching + navigateToPage('artists'); + setTimeout(() => { + const searchInput = document.querySelector('.artist-search-input, #artist-search'); + if (searchInput) { searchInput.value = artistName; searchInput.dispatchEvent(new Event('input')); } + }, 300); +} + function _toggleOrbExpand(el) { const g = el.closest('.wl-orb-group'); if (!g) return; diff --git a/webui/static/style.css b/webui/static/style.css index 7c4d1ee1..1aa7a051 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -57250,7 +57250,50 @@ body.reduce-effects *::after { .wl-orb-ring { position: absolute; inset: -2px; border-radius: 50%; border: 2px solid hsla(var(--orb-hue),60%,50%,0.3); z-index: 2; pointer-events: none; } .wl-orb-group.expanded .wl-orb-ring { border-color: hsla(var(--orb-hue),70%,60%,0.6); box-shadow: 0 0 16px hsla(var(--orb-hue),70%,50%,0.3); } -.wl-orb-label { font-size: 11px; font-weight: 600; color: rgba(255,255,255,0.7); text-align: center; max-width: 120px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +/* Enhancement 2: hover tooltip */ +.wl-orb-tooltip { + position: absolute; + bottom: calc(100% + 8px); + left: 50%; + transform: translateX(-50%) translateY(4px); + background: rgba(10,10,10,0.95); + border: 1px solid rgba(255,255,255,0.12); + border-radius: 8px; + padding: 6px 12px; + font-size: 12px; + font-weight: 600; + color: #fff; + text-align: center; + white-space: nowrap; + pointer-events: none; + opacity: 0; + transition: opacity 0.2s, transform 0.2s; + z-index: 20; + box-shadow: 0 4px 16px rgba(0,0,0,0.4); +} +.wl-orb-tooltip span { font-size: 10px; font-weight: 400; color: rgba(255,255,255,0.45); } +.wl-orb-group:not(.expanded):hover .wl-orb-tooltip { opacity: 1; transform: translateX(-50%) translateY(0); } +.wl-orb-group.expanded .wl-orb-tooltip { display: none; } + +/* Enhancement 3: pulse for next-in-queue */ +.wl-orb.orb-pulse .wl-orb-glow { animation: orbGlowSpin 8s linear infinite, orbPulse 2.5s ease-in-out infinite; } +@keyframes orbPulse { 0%,100% { filter: blur(3px) brightness(1); } 50% { filter: blur(5px) brightness(1.4); } } + +/* Enhancement 5: album art ring */ +.wl-orb-art-ring { position: absolute; inset: -16px; border-radius: 50%; z-index: 0; pointer-events: none; animation: artRingSpin 30s linear infinite; } +.wl-art-ring-item { position: absolute; width: 20px; height: 20px; border-radius: 50%; object-fit: cover; top: 50%; left: 50%; transform: rotate(var(--ring-angle)) translateY(-50%) translateX(calc(var(--ring-radius, 48px))); opacity: 0.5; border: 1px solid rgba(255,255,255,0.1); transition: opacity 0.3s; } +.wl-orb.orb-sm .wl-orb-art-ring { --ring-radius: 44px; } +.wl-orb.orb-md .wl-orb-art-ring { --ring-radius: 54px; } +.wl-orb.orb-lg .wl-orb-art-ring { --ring-radius: 66px; } +.wl-orb:hover .wl-art-ring-item { opacity: 0.85; } +@keyframes artRingSpin { to { transform: rotate(360deg); } } + +/* Enhancement 7: staggered entry animation */ +.wl-orb-group { animation: orbEntrance 0.5s ease backwards; } +@keyframes orbEntrance { from { opacity: 0; transform: translateY(20px) scale(0.9); } to { opacity: 1; transform: translateY(0) scale(1); } } + +.wl-orb-label { font-size: 11px; font-weight: 600; color: rgba(255,255,255,0.7); text-align: center; max-width: 120px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer; transition: color 0.15s; } +.wl-orb-label:hover { color: rgb(var(--accent-light-rgb)); text-decoration: underline; } .wl-orb-meta { font-size: 9px; color: rgba(255,255,255,0.3); text-align: center; } /* ── Expanded ── */