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>
|
||||||
</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 -->
|
<!-- Enhanced Library Management View -->
|
||||||
<div class="enhanced-view-container hidden" id="enhanced-view-container">
|
<div class="enhanced-view-container hidden" id="enhanced-view-container">
|
||||||
<!-- Populated dynamically by renderEnhancedView() -->
|
<!-- Populated dynamically by renderEnhancedView() -->
|
||||||
|
|
|
||||||
|
|
@ -742,325 +742,6 @@ function displayArtistDiscography(discography) {
|
||||||
/**
|
/**
|
||||||
* Load similar artists from MusicMap
|
* 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
|
* Restore cached completion data without re-scanning the database
|
||||||
|
|
|
||||||
|
|
@ -949,6 +949,11 @@ function populateArtistDetailPage(data) {
|
||||||
console.log(`📀 EPs:`, discography.eps);
|
console.log(`📀 EPs:`, discography.eps);
|
||||||
console.log(`📀 Singles:`, discography.singles);
|
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
|
// Update hero section with image, name, and stats
|
||||||
updateArtistHeroSection(artist, discography);
|
updateArtistHeroSection(artist, discography);
|
||||||
|
|
||||||
|
|
@ -966,6 +971,13 @@ function populateArtistDetailPage(data) {
|
||||||
if (libraryWatchlistBtn && data.spotify_artist && data.spotify_artist.spotify_artist_id) {
|
if (libraryWatchlistBtn && data.spotify_artist && data.spotify_artist.spotify_artist_id) {
|
||||||
initializeLibraryWatchlistButton(data.spotify_artist.spotify_artist_id, data.spotify_artist.spotify_artist_name);
|
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) {
|
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;
|
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