Add Similar Artists to standalone /artist-detail page, hide library-only UI for source artists
First increment of the artist-detail unification redesign. Delivers
the two most-visible missing pieces for source artists without touching
the hero layout — that's a later commit.
Changes:
- HTML: new #ad-similar-artists-section inside #artist-detail-main
(scoped IDs with 'ad-' prefix so they don't collide with the inline
Artists page, which has the same section using base IDs).
- shared-helpers.js: similar-artists helpers (loadSimilarArtists +
display/progressive/createBubble + lazy image loader) moved out of
artists.js. New _resolveSimilarArtistsTargets() resolver picks
whichever candidate set has a `.page.active` ancestor, so the same
function works on both the inline Artists page and the standalone
artist-detail page without caller changes.
- library.js populateArtistDetailPage: sets
document.body.dataset.artistSource = 'library' | 'source' before
rendering, and fires loadSimilarArtists(artist.name) after
populating the rest of the page.
- style.css: body[data-artist-source='source'] rules hide
library-only UI on the artist-detail page — Enhanced view toggle,
Status (owned/missing) filter, completion bars, enrichment
coverage, Top Tracks sidebar, Radio / Enhance Quality buttons,
"X owned / Y missing" section-stats counts. CSS-only, additive,
library artists completely unaffected.
Impact today:
- Library artists: Similar Artists section now appears at the
bottom of their detail page (previously only the inline Artists
page had it). All other UI unchanged.
- Source artists: still route to the inline Artists page (Part B
reverted earlier this session). The standalone page is now
source-ready infrastructure-wise, but source artists don't reach
it yet. A later commit will re-migrate source callers to the
standalone page once the hero rendering is also source-ready.
artists.js shrinks from 1903 -> 1584 lines (similar-artists block
extracted). shared-helpers.js grows correspondingly. 357/357 tests
still pass. No version bump — this is still 2.39 pending.
This commit is contained in:
parent
18146098a7
commit
6a76405444
5 changed files with 432 additions and 319 deletions
|
|
@ -2667,6 +2667,27 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Similar Artists Section (works for both library and source artists).
|
||||
Uses its own scoped IDs because the inline Artists page has a section
|
||||
with the same base IDs and both elements live in the DOM at once. -->
|
||||
<div class="similar-artists-section" id="ad-similar-artists-section">
|
||||
<div class="similar-artists-header">
|
||||
<h3 class="similar-artists-title">Similar Artists</h3>
|
||||
<p class="similar-artists-subtitle">Discover artists with a similar sound</p>
|
||||
</div>
|
||||
<div class="similar-artists-loading hidden" id="ad-similar-artists-loading">
|
||||
<div class="loading-spinner-small"></div>
|
||||
<span>Finding similar artists...</span>
|
||||
</div>
|
||||
<div class="similar-artists-error hidden" id="ad-similar-artists-error">
|
||||
<span class="error-icon">⚠️</span>
|
||||
<span class="error-text">Unable to load similar artists</span>
|
||||
</div>
|
||||
<div class="similar-artists-bubbles-container" id="ad-similar-artists-bubbles-container">
|
||||
<!-- Artist bubble cards will be populated here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Enhanced Library Management View -->
|
||||
<div class="enhanced-view-container hidden" id="enhanced-view-container">
|
||||
<!-- Populated dynamically by renderEnhancedView() -->
|
||||
|
|
|
|||
|
|
@ -742,325 +742,6 @@ function displayArtistDiscography(discography) {
|
|||
/**
|
||||
* Load similar artists from MusicMap
|
||||
*/
|
||||
async function loadSimilarArtists(artistName) {
|
||||
if (!artistName) {
|
||||
console.warn('⚠️ No artist name provided for similar artists');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`🔍 Loading similar artists for: ${artistName}`);
|
||||
|
||||
// Get DOM elements
|
||||
const section = document.getElementById('similar-artists-section');
|
||||
const loadingEl = document.getElementById('similar-artists-loading');
|
||||
const errorEl = document.getElementById('similar-artists-error');
|
||||
const container = document.getElementById('similar-artists-bubbles-container');
|
||||
|
||||
if (!section || !loadingEl || !errorEl || !container) {
|
||||
console.warn('⚠️ Similar artists section elements not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
loadingEl.classList.remove('hidden');
|
||||
errorEl.classList.add('hidden');
|
||||
container.innerHTML = '';
|
||||
section.style.display = 'block';
|
||||
|
||||
try {
|
||||
// Create new abort controller for this similar artists stream
|
||||
similarArtistsController = new AbortController();
|
||||
|
||||
// Use streaming endpoint for real-time bubble creation
|
||||
const url = `/api/artist/similar/${encodeURIComponent(artistName)}/stream`;
|
||||
console.log(`📡 Streaming from: ${url}`);
|
||||
|
||||
const response = await fetch(url, {
|
||||
signal: similarArtistsController.signal
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch similar artists: ${response.status}`);
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let artistCount = 0;
|
||||
|
||||
// Read the stream
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) {
|
||||
console.log('✅ Stream complete');
|
||||
break;
|
||||
}
|
||||
|
||||
// Decode the chunk and add to buffer
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// Process complete messages (separated by \n\n)
|
||||
const messages = buffer.split('\n\n');
|
||||
buffer = messages.pop() || ''; // Keep incomplete message in buffer
|
||||
|
||||
for (const message of messages) {
|
||||
if (!message.trim() || !message.startsWith('data: ')) continue;
|
||||
|
||||
try {
|
||||
const jsonData = JSON.parse(message.substring(6)); // Remove 'data: ' prefix
|
||||
|
||||
if (jsonData.error) {
|
||||
throw new Error(jsonData.error);
|
||||
}
|
||||
|
||||
if (jsonData.artist) {
|
||||
// Hide loading on first artist
|
||||
if (artistCount === 0) {
|
||||
loadingEl.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Create and append bubble immediately
|
||||
const bubble = createSimilarArtistBubble(jsonData.artist);
|
||||
container.appendChild(bubble);
|
||||
artistCount++;
|
||||
|
||||
console.log(`✅ Added bubble for: ${jsonData.artist.name} (${artistCount})`);
|
||||
}
|
||||
|
||||
if (jsonData.complete) {
|
||||
console.log(`🎉 Streaming complete: ${jsonData.total} artists`);
|
||||
|
||||
if (artistCount === 0) {
|
||||
loadingEl.classList.add('hidden');
|
||||
container.innerHTML = `
|
||||
<div style="width: 100%; text-align: center; padding: 40px 20px; color: rgba(255, 255, 255, 0.5);">
|
||||
<div style="font-size: 18px; margin-bottom: 8px;">🎵</div>
|
||||
<div style="font-size: 14px;">No similar artists found</div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
// Lazy load images for similar artists that don't have them
|
||||
lazyLoadSimilarArtistImages(container);
|
||||
}
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.error('❌ Error parsing stream message:', parseError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear the controller when done
|
||||
similarArtistsController = null;
|
||||
|
||||
} catch (error) {
|
||||
// Don't show error if it was aborted (user navigated away)
|
||||
if (error.name === 'AbortError') {
|
||||
console.log('⏹️ Similar artists stream aborted (user navigated to new artist)');
|
||||
loadingEl.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('❌ Error loading similar artists:', error);
|
||||
|
||||
// Hide loading, show error
|
||||
loadingEl.classList.add('hidden');
|
||||
errorEl.classList.remove('hidden');
|
||||
|
||||
// Also show error message in container
|
||||
container.innerHTML = `
|
||||
<div style="width: 100%; text-align: center; padding: 40px 20px; color: rgba(239, 68, 68, 0.7);">
|
||||
<div style="font-size: 18px; margin-bottom: 8px;">⚠️</div>
|
||||
<div style="font-size: 14px;">${error.message}</div>
|
||||
</div>
|
||||
`;
|
||||
} finally {
|
||||
// Always clear the controller
|
||||
similarArtistsController = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy load images for similar artist bubbles that don't have images
|
||||
*/
|
||||
async function lazyLoadSimilarArtistImages(container) {
|
||||
if (!container) return;
|
||||
|
||||
const bubblesNeedingImages = container.querySelectorAll('.similar-artist-bubble[data-needs-image="true"]');
|
||||
|
||||
if (bubblesNeedingImages.length === 0) {
|
||||
console.log('✅ All similar artist bubbles have images');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`🖼️ Lazy loading images for ${bubblesNeedingImages.length} similar artists`);
|
||||
|
||||
// Load images in parallel batches
|
||||
const batchSize = 5;
|
||||
const bubbles = Array.from(bubblesNeedingImages);
|
||||
|
||||
for (let i = 0; i < bubbles.length; i += batchSize) {
|
||||
const batch = bubbles.slice(i, i + batchSize);
|
||||
|
||||
await Promise.all(batch.map(async (bubble) => {
|
||||
const artistId = bubble.getAttribute('data-artist-id');
|
||||
const artistSource = bubble.getAttribute('data-artist-source') || '';
|
||||
const artistPlugin = bubble.getAttribute('data-artist-plugin') || '';
|
||||
if (!artistId) return;
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (artistSource) params.set('source', artistSource);
|
||||
if (artistPlugin) params.set('plugin', artistPlugin);
|
||||
|
||||
const imageUrl = params.toString()
|
||||
? `/api/artist/${encodeURIComponent(artistId)}/image?${params.toString()}`
|
||||
: `/api/artist/${encodeURIComponent(artistId)}/image`;
|
||||
|
||||
const response = await fetch(imageUrl);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.image_url) {
|
||||
const imageContainer = bubble.querySelector('.similar-artist-bubble-image');
|
||||
if (imageContainer) {
|
||||
const artistName = bubble.querySelector('.similar-artist-bubble-name')?.textContent || 'Artist';
|
||||
imageContainer.innerHTML = `<img src="${data.image_url}" alt="${artistName}">`;
|
||||
bubble.setAttribute('data-needs-image', 'false');
|
||||
console.log(`✅ Loaded image for similar artist ${artistId}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`⚠️ Failed to load image for similar artist ${artistId}:`, error);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
console.log('✅ Finished lazy loading similar artist images');
|
||||
}
|
||||
|
||||
/**
|
||||
* Display similar artist bubble cards progressively (one at a time with delay)
|
||||
*/
|
||||
function displaySimilarArtistsProgressively(artists) {
|
||||
const container = document.getElementById('similar-artists-bubbles-container');
|
||||
|
||||
if (!container) {
|
||||
console.warn('⚠️ Similar artists container not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear container
|
||||
container.innerHTML = '';
|
||||
|
||||
// Add each bubble with a delay to simulate progressive loading
|
||||
artists.forEach((artist, index) => {
|
||||
setTimeout(() => {
|
||||
const bubble = createSimilarArtistBubble(artist);
|
||||
container.appendChild(bubble);
|
||||
}, index * 100); // 100ms delay between each bubble
|
||||
});
|
||||
|
||||
console.log(`✅ Displaying ${artists.length} similar artist bubbles progressively`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display similar artist bubble cards (all at once - legacy)
|
||||
*/
|
||||
function displaySimilarArtists(artists) {
|
||||
const container = document.getElementById('similar-artists-bubbles-container');
|
||||
|
||||
if (!container) {
|
||||
console.warn('⚠️ Similar artists container not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear container
|
||||
container.innerHTML = '';
|
||||
|
||||
// Create bubble cards with staggered animation
|
||||
artists.forEach((artist, index) => {
|
||||
const bubble = createSimilarArtistBubble(artist);
|
||||
|
||||
// Add staggered animation delay (50ms per bubble)
|
||||
bubble.style.animationDelay = `${index * 0.05}s`;
|
||||
|
||||
container.appendChild(bubble);
|
||||
});
|
||||
|
||||
console.log(`✅ Displayed ${artists.length} similar artist bubbles`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a similar artist bubble card element
|
||||
*/
|
||||
function createSimilarArtistBubble(artist) {
|
||||
// Create bubble container
|
||||
const bubble = document.createElement('div');
|
||||
bubble.className = 'similar-artist-bubble';
|
||||
bubble.setAttribute('data-artist-id', artist.id);
|
||||
bubble.setAttribute('data-artist-source', artist.source || '');
|
||||
if (artist.plugin) {
|
||||
bubble.setAttribute('data-artist-plugin', artist.plugin);
|
||||
}
|
||||
|
||||
// Track if image needs lazy loading
|
||||
const hasImage = artist.image_url && artist.image_url.trim() !== '';
|
||||
bubble.setAttribute('data-needs-image', hasImage ? 'false' : 'true');
|
||||
|
||||
// Create image container
|
||||
const imageContainer = document.createElement('div');
|
||||
imageContainer.className = 'similar-artist-bubble-image';
|
||||
|
||||
if (hasImage) {
|
||||
const img = document.createElement('img');
|
||||
img.src = artist.image_url;
|
||||
img.alt = artist.name;
|
||||
|
||||
// Handle image load error
|
||||
img.onerror = () => {
|
||||
console.log(`Failed to load image for ${artist.name}`);
|
||||
imageContainer.innerHTML = `<div class="similar-artist-bubble-image-fallback">🎵</div>`;
|
||||
bubble.setAttribute('data-needs-image', 'true');
|
||||
};
|
||||
|
||||
imageContainer.appendChild(img);
|
||||
} else {
|
||||
// No image - show fallback (will be lazy loaded)
|
||||
imageContainer.innerHTML = `<div class="similar-artist-bubble-image-fallback">🎵</div>`;
|
||||
}
|
||||
|
||||
// Create name element
|
||||
const name = document.createElement('div');
|
||||
name.className = 'similar-artist-bubble-name';
|
||||
name.textContent = artist.name;
|
||||
name.title = artist.name; // Tooltip for full name
|
||||
|
||||
// Optional: Create genres element (hidden by default in CSS)
|
||||
const genres = document.createElement('div');
|
||||
genres.className = 'similar-artist-bubble-genres';
|
||||
if (artist.genres && artist.genres.length > 0) {
|
||||
genres.textContent = artist.genres.slice(0, 2).join(', ');
|
||||
}
|
||||
|
||||
// Assemble bubble
|
||||
bubble.appendChild(imageContainer);
|
||||
bubble.appendChild(name);
|
||||
if (artist.genres && artist.genres.length > 0) {
|
||||
bubble.appendChild(genres);
|
||||
}
|
||||
|
||||
// Add click handler to navigate to artist detail page
|
||||
bubble.addEventListener('click', () => {
|
||||
console.log(`🎵 Clicked similar artist: ${artist.name} (ID: ${artist.id})`);
|
||||
// Navigate to this artist's detail page (same as clicking from search results)
|
||||
selectArtistForDetail(
|
||||
artist,
|
||||
artist.source ? { source: artist.source, plugin: artist.plugin } : {}
|
||||
);
|
||||
});
|
||||
|
||||
return bubble;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore cached completion data without re-scanning the database
|
||||
|
|
|
|||
|
|
@ -949,6 +949,11 @@ function populateArtistDetailPage(data) {
|
|||
console.log(`📀 EPs:`, discography.eps);
|
||||
console.log(`📀 Singles:`, discography.singles);
|
||||
|
||||
// Tag the body so CSS can hide library-only UI for source artists (e.g.
|
||||
// the Enhanced view toggle, the Status filter, completion bars). Set
|
||||
// BEFORE rendering so any layout-dependent code sees the right state.
|
||||
document.body.dataset.artistSource = (artist && artist.server_source) ? 'library' : 'source';
|
||||
|
||||
// Update hero section with image, name, and stats
|
||||
updateArtistHeroSection(artist, discography);
|
||||
|
||||
|
|
@ -966,6 +971,13 @@ function populateArtistDetailPage(data) {
|
|||
if (libraryWatchlistBtn && data.spotify_artist && data.spotify_artist.spotify_artist_id) {
|
||||
initializeLibraryWatchlistButton(data.spotify_artist.spotify_artist_id, data.spotify_artist.spotify_artist_name);
|
||||
}
|
||||
|
||||
// Load Similar Artists section (works for both library + source artists via
|
||||
// MusicMap name lookup). Fire-and-forget — the function handles its own
|
||||
// loading state and errors.
|
||||
if (artist && artist.name && typeof loadSimilarArtists === 'function') {
|
||||
loadSimilarArtists(artist.name);
|
||||
}
|
||||
}
|
||||
|
||||
function updateArtistDetailImage(imageUrl, artistName) {
|
||||
|
|
|
|||
|
|
@ -2759,3 +2759,366 @@ function renderEnrichmentCards(enrichment) {
|
|||
}
|
||||
|
||||
// ===============================
|
||||
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Similar Artists — fetch + render via MusicMap. Source-agnostic (artist name
|
||||
// based), works for both library and metadata-source artists. Targets DOM IDs
|
||||
// #similar-artists-loading, #similar-artists-error, #similar-artists-bubbles-
|
||||
// container that live on the artist-detail page.
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// Similar artists lives on two pages (the inline Artists page and the standalone
|
||||
// artist-detail page), each with its own set of IDs so they don't collide in the
|
||||
// DOM. This resolver picks the set whose `.page` ancestor is currently active.
|
||||
function _resolveSimilarArtistsTargets() {
|
||||
const candidates = [
|
||||
// standalone artist-detail page (scoped ids)
|
||||
{ section: 'ad-similar-artists-section', loading: 'ad-similar-artists-loading', error: 'ad-similar-artists-error', bubbles: 'ad-similar-artists-bubbles-container' },
|
||||
// legacy inline Artists page (base ids)
|
||||
{ section: 'similar-artists-section', loading: 'similar-artists-loading', error: 'similar-artists-error', bubbles: 'similar-artists-bubbles-container' },
|
||||
];
|
||||
// Prefer the set whose parent .page is currently active.
|
||||
for (const c of candidates) {
|
||||
const el = document.getElementById(c.section);
|
||||
if (el && el.closest('.page.active')) {
|
||||
return {
|
||||
section: el,
|
||||
loadingEl: document.getElementById(c.loading),
|
||||
errorEl: document.getElementById(c.error),
|
||||
container: document.getElementById(c.bubbles),
|
||||
};
|
||||
}
|
||||
}
|
||||
// Fallback: return the first set that exists at all.
|
||||
for (const c of candidates) {
|
||||
const el = document.getElementById(c.section);
|
||||
if (el) {
|
||||
return {
|
||||
section: el,
|
||||
loadingEl: document.getElementById(c.loading),
|
||||
errorEl: document.getElementById(c.error),
|
||||
container: document.getElementById(c.bubbles),
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function loadSimilarArtists(artistName) {
|
||||
if (!artistName) {
|
||||
console.warn('⚠️ No artist name provided for similar artists');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`🔍 Loading similar artists for: ${artistName}`);
|
||||
|
||||
const targets = _resolveSimilarArtistsTargets();
|
||||
if (!targets) {
|
||||
console.warn('⚠️ Similar artists section elements not found on any active page');
|
||||
return;
|
||||
}
|
||||
const { section, loadingEl, errorEl, container } = targets;
|
||||
|
||||
// Show loading state
|
||||
loadingEl.classList.remove('hidden');
|
||||
errorEl.classList.add('hidden');
|
||||
container.innerHTML = '';
|
||||
section.style.display = 'block';
|
||||
|
||||
try {
|
||||
// Create new abort controller for this similar artists stream
|
||||
similarArtistsController = new AbortController();
|
||||
|
||||
// Use streaming endpoint for real-time bubble creation
|
||||
const url = `/api/artist/similar/${encodeURIComponent(artistName)}/stream`;
|
||||
console.log(`📡 Streaming from: ${url}`);
|
||||
|
||||
const response = await fetch(url, {
|
||||
signal: similarArtistsController.signal
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch similar artists: ${response.status}`);
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let artistCount = 0;
|
||||
|
||||
// Read the stream
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) {
|
||||
console.log('✅ Stream complete');
|
||||
break;
|
||||
}
|
||||
|
||||
// Decode the chunk and add to buffer
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// Process complete messages (separated by \n\n)
|
||||
const messages = buffer.split('\n\n');
|
||||
buffer = messages.pop() || ''; // Keep incomplete message in buffer
|
||||
|
||||
for (const message of messages) {
|
||||
if (!message.trim() || !message.startsWith('data: ')) continue;
|
||||
|
||||
try {
|
||||
const jsonData = JSON.parse(message.substring(6)); // Remove 'data: ' prefix
|
||||
|
||||
if (jsonData.error) {
|
||||
throw new Error(jsonData.error);
|
||||
}
|
||||
|
||||
if (jsonData.artist) {
|
||||
// Hide loading on first artist
|
||||
if (artistCount === 0) {
|
||||
loadingEl.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Create and append bubble immediately
|
||||
const bubble = createSimilarArtistBubble(jsonData.artist);
|
||||
container.appendChild(bubble);
|
||||
artistCount++;
|
||||
|
||||
console.log(`✅ Added bubble for: ${jsonData.artist.name} (${artistCount})`);
|
||||
}
|
||||
|
||||
if (jsonData.complete) {
|
||||
console.log(`🎉 Streaming complete: ${jsonData.total} artists`);
|
||||
|
||||
if (artistCount === 0) {
|
||||
loadingEl.classList.add('hidden');
|
||||
container.innerHTML = `
|
||||
<div style="width: 100%; text-align: center; padding: 40px 20px; color: rgba(255, 255, 255, 0.5);">
|
||||
<div style="font-size: 18px; margin-bottom: 8px;">🎵</div>
|
||||
<div style="font-size: 14px;">No similar artists found</div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
// Lazy load images for similar artists that don't have them
|
||||
lazyLoadSimilarArtistImages(container);
|
||||
}
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.error('❌ Error parsing stream message:', parseError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear the controller when done
|
||||
similarArtistsController = null;
|
||||
|
||||
} catch (error) {
|
||||
// Don't show error if it was aborted (user navigated away)
|
||||
if (error.name === 'AbortError') {
|
||||
console.log('⏹️ Similar artists stream aborted (user navigated to new artist)');
|
||||
loadingEl.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('❌ Error loading similar artists:', error);
|
||||
|
||||
// Hide loading, show error
|
||||
loadingEl.classList.add('hidden');
|
||||
errorEl.classList.remove('hidden');
|
||||
|
||||
// Also show error message in container
|
||||
container.innerHTML = `
|
||||
<div style="width: 100%; text-align: center; padding: 40px 20px; color: rgba(239, 68, 68, 0.7);">
|
||||
<div style="font-size: 18px; margin-bottom: 8px;">⚠️</div>
|
||||
<div style="font-size: 14px;">${error.message}</div>
|
||||
</div>
|
||||
`;
|
||||
} finally {
|
||||
// Always clear the controller
|
||||
similarArtistsController = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy load images for similar artist bubbles that don't have images
|
||||
*/
|
||||
async function lazyLoadSimilarArtistImages(container) {
|
||||
if (!container) return;
|
||||
|
||||
const bubblesNeedingImages = container.querySelectorAll('.similar-artist-bubble[data-needs-image="true"]');
|
||||
|
||||
if (bubblesNeedingImages.length === 0) {
|
||||
console.log('✅ All similar artist bubbles have images');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`🖼️ Lazy loading images for ${bubblesNeedingImages.length} similar artists`);
|
||||
|
||||
// Load images in parallel batches
|
||||
const batchSize = 5;
|
||||
const bubbles = Array.from(bubblesNeedingImages);
|
||||
|
||||
for (let i = 0; i < bubbles.length; i += batchSize) {
|
||||
const batch = bubbles.slice(i, i + batchSize);
|
||||
|
||||
await Promise.all(batch.map(async (bubble) => {
|
||||
const artistId = bubble.getAttribute('data-artist-id');
|
||||
const artistSource = bubble.getAttribute('data-artist-source') || '';
|
||||
const artistPlugin = bubble.getAttribute('data-artist-plugin') || '';
|
||||
if (!artistId) return;
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (artistSource) params.set('source', artistSource);
|
||||
if (artistPlugin) params.set('plugin', artistPlugin);
|
||||
|
||||
const imageUrl = params.toString()
|
||||
? `/api/artist/${encodeURIComponent(artistId)}/image?${params.toString()}`
|
||||
: `/api/artist/${encodeURIComponent(artistId)}/image`;
|
||||
|
||||
const response = await fetch(imageUrl);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.image_url) {
|
||||
const imageContainer = bubble.querySelector('.similar-artist-bubble-image');
|
||||
if (imageContainer) {
|
||||
const artistName = bubble.querySelector('.similar-artist-bubble-name')?.textContent || 'Artist';
|
||||
imageContainer.innerHTML = `<img src="${data.image_url}" alt="${artistName}">`;
|
||||
bubble.setAttribute('data-needs-image', 'false');
|
||||
console.log(`✅ Loaded image for similar artist ${artistId}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`⚠️ Failed to load image for similar artist ${artistId}:`, error);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
console.log('✅ Finished lazy loading similar artist images');
|
||||
}
|
||||
|
||||
/**
|
||||
* Display similar artist bubble cards progressively (one at a time with delay)
|
||||
*/
|
||||
function displaySimilarArtistsProgressively(artists) {
|
||||
const targets = _resolveSimilarArtistsTargets();
|
||||
const container = targets && targets.container;
|
||||
|
||||
if (!container) {
|
||||
console.warn('⚠️ Similar artists container not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear container
|
||||
container.innerHTML = '';
|
||||
|
||||
// Add each bubble with a delay to simulate progressive loading
|
||||
artists.forEach((artist, index) => {
|
||||
setTimeout(() => {
|
||||
const bubble = createSimilarArtistBubble(artist);
|
||||
container.appendChild(bubble);
|
||||
}, index * 100); // 100ms delay between each bubble
|
||||
});
|
||||
|
||||
console.log(`✅ Displaying ${artists.length} similar artist bubbles progressively`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display similar artist bubble cards (all at once - legacy)
|
||||
*/
|
||||
function displaySimilarArtists(artists) {
|
||||
const targets = _resolveSimilarArtistsTargets();
|
||||
const container = targets && targets.container;
|
||||
|
||||
if (!container) {
|
||||
console.warn('⚠️ Similar artists container not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear container
|
||||
container.innerHTML = '';
|
||||
|
||||
// Create bubble cards with staggered animation
|
||||
artists.forEach((artist, index) => {
|
||||
const bubble = createSimilarArtistBubble(artist);
|
||||
|
||||
// Add staggered animation delay (50ms per bubble)
|
||||
bubble.style.animationDelay = `${index * 0.05}s`;
|
||||
|
||||
container.appendChild(bubble);
|
||||
});
|
||||
|
||||
console.log(`✅ Displayed ${artists.length} similar artist bubbles`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a similar artist bubble card element
|
||||
*/
|
||||
function createSimilarArtistBubble(artist) {
|
||||
// Create bubble container
|
||||
const bubble = document.createElement('div');
|
||||
bubble.className = 'similar-artist-bubble';
|
||||
bubble.setAttribute('data-artist-id', artist.id);
|
||||
bubble.setAttribute('data-artist-source', artist.source || '');
|
||||
if (artist.plugin) {
|
||||
bubble.setAttribute('data-artist-plugin', artist.plugin);
|
||||
}
|
||||
|
||||
// Track if image needs lazy loading
|
||||
const hasImage = artist.image_url && artist.image_url.trim() !== '';
|
||||
bubble.setAttribute('data-needs-image', hasImage ? 'false' : 'true');
|
||||
|
||||
// Create image container
|
||||
const imageContainer = document.createElement('div');
|
||||
imageContainer.className = 'similar-artist-bubble-image';
|
||||
|
||||
if (hasImage) {
|
||||
const img = document.createElement('img');
|
||||
img.src = artist.image_url;
|
||||
img.alt = artist.name;
|
||||
|
||||
// Handle image load error
|
||||
img.onerror = () => {
|
||||
console.log(`Failed to load image for ${artist.name}`);
|
||||
imageContainer.innerHTML = `<div class="similar-artist-bubble-image-fallback">🎵</div>`;
|
||||
bubble.setAttribute('data-needs-image', 'true');
|
||||
};
|
||||
|
||||
imageContainer.appendChild(img);
|
||||
} else {
|
||||
// No image - show fallback (will be lazy loaded)
|
||||
imageContainer.innerHTML = `<div class="similar-artist-bubble-image-fallback">🎵</div>`;
|
||||
}
|
||||
|
||||
// Create name element
|
||||
const name = document.createElement('div');
|
||||
name.className = 'similar-artist-bubble-name';
|
||||
name.textContent = artist.name;
|
||||
name.title = artist.name; // Tooltip for full name
|
||||
|
||||
// Optional: Create genres element (hidden by default in CSS)
|
||||
const genres = document.createElement('div');
|
||||
genres.className = 'similar-artist-bubble-genres';
|
||||
if (artist.genres && artist.genres.length > 0) {
|
||||
genres.textContent = artist.genres.slice(0, 2).join(', ');
|
||||
}
|
||||
|
||||
// Assemble bubble
|
||||
bubble.appendChild(imageContainer);
|
||||
bubble.appendChild(name);
|
||||
if (artist.genres && artist.genres.length > 0) {
|
||||
bubble.appendChild(genres);
|
||||
}
|
||||
|
||||
// Add click handler to navigate to artist detail page
|
||||
bubble.addEventListener('click', () => {
|
||||
console.log(`🎵 Clicked similar artist: ${artist.name} (ID: ${artist.id})`);
|
||||
// Navigate to this artist's detail page (same as clicking from search results)
|
||||
selectArtistForDetail(
|
||||
artist,
|
||||
artist.source ? { source: artist.source, plugin: artist.plugin } : {}
|
||||
);
|
||||
});
|
||||
|
||||
return bubble;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59377,3 +59377,39 @@ body.reduce-effects *::after {
|
|||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* =========================================================================
|
||||
Source-artist visibility rules
|
||||
=========================================================================
|
||||
When the artist-detail page is rendering a source artist (Spotify / Deezer /
|
||||
iTunes / etc. — not in the local library), hide library-only UI. The body
|
||||
data-artist-source attribute is set by library.js populateArtistDetailPage
|
||||
before rendering.
|
||||
*/
|
||||
|
||||
/* Library-only: status filter (owned/missing only makes sense when you own things) */
|
||||
body[data-artist-source="source"] #artist-detail-page .filter-group:has(.filter-label:only-child),
|
||||
body[data-artist-source="source"] #artist-detail-page .discography-filter-btn[data-filter="ownership"] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Library-only: Enhanced view toggle + container (per-track editing requires owned tracks) */
|
||||
body[data-artist-source="source"] #artist-detail-page .enhanced-view-toggle-btn,
|
||||
body[data-artist-source="source"] #artist-detail-page #enhanced-view-container {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Library-only: completion bars, enrichment coverage, Top Tracks sidebar, Radio / Enhance Quality buttons */
|
||||
body[data-artist-source="source"] #artist-detail-page .collection-overview,
|
||||
body[data-artist-source="source"] #artist-detail-page .artist-enrichment-coverage,
|
||||
body[data-artist-source="source"] #artist-detail-page #artist-hero-sidebar,
|
||||
body[data-artist-source="source"] #artist-detail-page #library-artist-radio-btn,
|
||||
body[data-artist-source="source"] #artist-detail-page #library-artist-enhance-btn {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Library-only: section-stats "0 owned / 0 missing" counts don't apply */
|
||||
body[data-artist-source="source"] #artist-detail-page .section-stats {
|
||||
display: none !important;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue