Wishlist Nebula enhancements — artist photos, art ring, animations

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
This commit is contained in:
Broque Thomas 2026-04-16 16:33:16 -07:00
parent dd26437125
commit 16ad6184ed
2 changed files with 108 additions and 14 deletions

View file

@ -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 += `<div class="wl-orb-group" data-artist="${escapeHtml(name)}">`;
html += `<div class="wl-orb ${sz}" style="--orb-hue:${hue}" onclick="_toggleOrbExpand(this)">`;
// 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 += `<div class="wl-orb-group" data-artist="${escapeHtml(name)}" style="animation-delay:${delay}ms">`;
// Enhancement 2: hover tooltip
html += `<div class="wl-orb-tooltip">${escapeHtml(name)}<br><span>${total} track${total !== 1 ? 's' : ''}</span></div>`;
html += `<div class="wl-orb ${sz}${pulseClass}" style="--orb-hue:${hue}" onclick="_toggleOrbExpand(this)">`;
html += `<div class="wl-orb-glow"></div>`;
html += img ? `<img class="wl-orb-img" src="${img}" alt="" loading="lazy">` : `<div class="wl-orb-initials">${escapeHtml(name.substring(0, 2).toUpperCase())}</div>`;
html += img ? `<img class="wl-orb-img" src="${img}" alt="">` : `<div class="wl-orb-initials">${escapeHtml(name.substring(0, 2).toUpperCase())}</div>`;
html += `<div class="wl-orb-ring"></div>`;
html += `</div>`;
html += `<div class="wl-orb-label">${escapeHtml(name)}</div>`;
// 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 += `<div class="wl-orb-art-ring">`;
ringCovers.forEach((url, i) => {
const angle = (360 / ringCovers.length) * i;
html += `<img class="wl-art-ring-item" src="${url}" style="--ring-angle:${angle}deg" alt="">`;
});
html += `</div>`;
}
html += `</div>`; // /orb
// Enhancement 8: clickable artist name → navigate to artist detail
html += `<div class="wl-orb-label" onclick="event.stopPropagation(); _navigateToArtistFromWishlist('${escapeHtml(name)}')" title="View artist">${escapeHtml(name)}</div>`;
html += `<div class="wl-orb-meta">${total} track${total !== 1 ? 's' : ''}</div>`;
// Expanded content
html += `<div class="wl-orb-expanded">`;
if (data.albums.size > 0) {
html += `<div class="wl-orb-albums">`;
@ -40610,11 +40650,22 @@ function _renderWishlistNebula(albumTracks, singleTracks) {
}
html += `</div>`;
}
html += `</div></div>`;
}
html += `</div></div>`; // /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;

View file

@ -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 ── */