similar artists start
This commit is contained in:
parent
2f8bad23a8
commit
ea9f351bdb
4 changed files with 796 additions and 3 deletions
237
web_server.py
237
web_server.py
|
|
@ -3594,6 +3594,243 @@ def debug_library_photos():
|
|||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route('/api/artist/similar/<path:artist_name>/stream')
|
||||
def get_similar_artists_stream(artist_name):
|
||||
"""
|
||||
Stream similar artists from MusicMap and match them to Spotify one by one
|
||||
|
||||
Args:
|
||||
artist_name: The artist name to find similar artists for
|
||||
|
||||
Returns:
|
||||
Server-Sent Events stream with each matched artist
|
||||
"""
|
||||
def generate():
|
||||
try:
|
||||
print(f"🎵 Streaming similar artists for: {artist_name}")
|
||||
|
||||
# Import required libraries
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
# Construct MusicMap URL
|
||||
url_artist = artist_name.lower().replace(' ', '+')
|
||||
musicmap_url = f'https://www.music-map.com/{url_artist}'
|
||||
|
||||
print(f"🌐 Fetching MusicMap: {musicmap_url}")
|
||||
|
||||
# Set headers to mimic a browser
|
||||
headers = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.5',
|
||||
}
|
||||
|
||||
# Fetch MusicMap page
|
||||
response = requests.get(musicmap_url, headers=headers, timeout=10)
|
||||
response.raise_for_status()
|
||||
|
||||
# Parse HTML
|
||||
soup = BeautifulSoup(response.text, 'html.parser')
|
||||
gnod_map = soup.find(id='gnodMap')
|
||||
|
||||
if not gnod_map:
|
||||
yield f"data: {json.dumps({'error': 'Could not find artist map on MusicMap'})}\n\n"
|
||||
return
|
||||
|
||||
# Extract similar artist names
|
||||
all_anchors = gnod_map.find_all('a')
|
||||
searched_artist_lower = artist_name.lower().strip()
|
||||
|
||||
similar_artist_names = []
|
||||
for anchor in all_anchors:
|
||||
artist_text = anchor.get_text(strip=True)
|
||||
|
||||
# Skip if this is the searched artist
|
||||
if artist_text.lower() == searched_artist_lower:
|
||||
continue
|
||||
|
||||
similar_artist_names.append(artist_text)
|
||||
|
||||
print(f"📦 Found {len(similar_artist_names)} similar artists from MusicMap")
|
||||
|
||||
# Initialize Spotify client
|
||||
if not spotify_client or not spotify_client.is_authenticated():
|
||||
yield f"data: {json.dumps({'error': 'Spotify not authenticated'})}\n\n"
|
||||
return
|
||||
|
||||
# Match each artist to Spotify one by one and stream results
|
||||
max_artists = 20
|
||||
matched_count = 0
|
||||
|
||||
for artist_name_to_match in similar_artist_names[:max_artists]:
|
||||
try:
|
||||
print(f"🔍 Matching to Spotify: {artist_name_to_match}")
|
||||
|
||||
# Search Spotify for the artist
|
||||
results = spotify_client.search_artists(artist_name_to_match, limit=1)
|
||||
|
||||
if results and len(results) > 0:
|
||||
spotify_artist = results[0]
|
||||
|
||||
artist_data = {
|
||||
'id': spotify_artist.id,
|
||||
'name': spotify_artist.name,
|
||||
'image_url': spotify_artist.image_url if hasattr(spotify_artist, 'image_url') else None,
|
||||
'genres': spotify_artist.genres if hasattr(spotify_artist, 'genres') else [],
|
||||
'popularity': spotify_artist.popularity if hasattr(spotify_artist, 'popularity') else 0
|
||||
}
|
||||
|
||||
# Stream this matched artist immediately
|
||||
yield f"data: {json.dumps({'artist': artist_data})}\n\n"
|
||||
matched_count += 1
|
||||
|
||||
print(f"✅ Matched and streamed: {spotify_artist.name}")
|
||||
else:
|
||||
print(f"❌ No Spotify match found for: {artist_name_to_match}")
|
||||
|
||||
except Exception as match_error:
|
||||
print(f"⚠️ Error matching {artist_name_to_match}: {match_error}")
|
||||
continue
|
||||
|
||||
# Send completion message
|
||||
yield f"data: {json.dumps({'complete': True, 'total': matched_count})}\n\n"
|
||||
print(f"✅ Streaming complete: {matched_count} artists matched")
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"❌ Error fetching MusicMap: {e}")
|
||||
yield f"data: {json.dumps({'error': f'Failed to fetch from MusicMap: {str(e)}'})}\n\n"
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error streaming similar artists: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
yield f"data: {json.dumps({'error': str(e)})}\n\n"
|
||||
|
||||
return Response(generate(), mimetype='text/event-stream')
|
||||
|
||||
@app.route('/api/artist/similar/<path:artist_name>')
|
||||
def get_similar_artists(artist_name):
|
||||
"""
|
||||
Get similar artists from MusicMap and match them to Spotify (legacy batch endpoint)
|
||||
|
||||
Args:
|
||||
artist_name: The artist name to find similar artists for
|
||||
|
||||
Returns:
|
||||
JSON with similar artists matched to Spotify data
|
||||
"""
|
||||
try:
|
||||
print(f"🎵 Getting similar artists for: {artist_name}")
|
||||
|
||||
# Import required libraries
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
# Construct MusicMap URL
|
||||
url_artist = artist_name.lower().replace(' ', '+')
|
||||
musicmap_url = f'https://www.music-map.com/{url_artist}'
|
||||
|
||||
print(f"🌐 Fetching MusicMap: {musicmap_url}")
|
||||
|
||||
# Set headers to mimic a browser
|
||||
headers = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.5',
|
||||
}
|
||||
|
||||
# Fetch MusicMap page
|
||||
response = requests.get(musicmap_url, headers=headers, timeout=10)
|
||||
response.raise_for_status()
|
||||
|
||||
# Parse HTML
|
||||
soup = BeautifulSoup(response.text, 'html.parser')
|
||||
gnod_map = soup.find(id='gnodMap')
|
||||
|
||||
if not gnod_map:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "Could not find artist map on MusicMap"
|
||||
}), 404
|
||||
|
||||
# Extract similar artist names
|
||||
all_anchors = gnod_map.find_all('a')
|
||||
searched_artist_lower = artist_name.lower().strip()
|
||||
|
||||
similar_artist_names = []
|
||||
for anchor in all_anchors:
|
||||
artist_text = anchor.get_text(strip=True)
|
||||
|
||||
# Skip if this is the searched artist
|
||||
if artist_text.lower() == searched_artist_lower:
|
||||
continue
|
||||
|
||||
similar_artist_names.append(artist_text)
|
||||
|
||||
print(f"📦 Found {len(similar_artist_names)} similar artists from MusicMap")
|
||||
|
||||
# Initialize Spotify client
|
||||
if not spotify_client or not spotify_client.is_authenticated():
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "Spotify not authenticated"
|
||||
}), 401
|
||||
|
||||
# Match each artist to Spotify (limit to first 20 for performance)
|
||||
matched_artists = []
|
||||
max_artists = 20
|
||||
|
||||
for artist_name_to_match in similar_artist_names[:max_artists]:
|
||||
try:
|
||||
print(f"🔍 Matching to Spotify: {artist_name_to_match}")
|
||||
|
||||
# Search Spotify for the artist
|
||||
results = spotify_client.search_artists(artist_name_to_match, limit=1)
|
||||
|
||||
if results and len(results) > 0:
|
||||
spotify_artist = results[0]
|
||||
|
||||
matched_artists.append({
|
||||
'id': spotify_artist.id,
|
||||
'name': spotify_artist.name,
|
||||
'image_url': spotify_artist.image_url if hasattr(spotify_artist, 'image_url') else None,
|
||||
'genres': spotify_artist.genres if hasattr(spotify_artist, 'genres') else [],
|
||||
'popularity': spotify_artist.popularity if hasattr(spotify_artist, 'popularity') else 0
|
||||
})
|
||||
|
||||
print(f"✅ Matched: {spotify_artist.name}")
|
||||
else:
|
||||
print(f"❌ No Spotify match found for: {artist_name_to_match}")
|
||||
|
||||
except Exception as match_error:
|
||||
print(f"⚠️ Error matching {artist_name_to_match}: {match_error}")
|
||||
continue
|
||||
|
||||
print(f"✅ Successfully matched {len(matched_artists)} artists to Spotify")
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"artist": artist_name,
|
||||
"similar_artists": matched_artists,
|
||||
"total_found": len(similar_artist_names),
|
||||
"total_matched": len(matched_artists)
|
||||
})
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"❌ Error fetching MusicMap: {e}")
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": f"Failed to fetch from MusicMap: {str(e)}"
|
||||
}), 500
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error getting similar artists: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}), 500
|
||||
|
||||
@app.route('/api/artist/<artist_id>/discography', methods=['GET'])
|
||||
def get_artist_discography(artist_id):
|
||||
"""Get an artist's complete discography (albums and singles)"""
|
||||
|
|
|
|||
|
|
@ -1268,13 +1268,38 @@
|
|||
<!-- Album cards will be populated here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="tab-content" id="singles-content">
|
||||
<div class="singles-cards-container" id="singles-cards-container">
|
||||
<!-- Singles cards will be populated here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Similar Artists Section -->
|
||||
<div class="similar-artists-section" id="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>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div class="similar-artists-loading hidden" id="similar-artists-loading">
|
||||
<div class="loading-spinner-small"></div>
|
||||
<span>Finding similar artists...</span>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div class="similar-artists-error hidden" id="similar-artists-error">
|
||||
<span class="error-icon">⚠️</span>
|
||||
<span class="error-text">Unable to load similar artists</span>
|
||||
</div>
|
||||
|
||||
<!-- Similar Artists Bubbles Container -->
|
||||
<div class="similar-artists-bubbles-container" id="similar-artists-bubbles-container">
|
||||
<!-- Artist bubble cards will be populated here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -15022,6 +15022,12 @@ async function loadArtistDiscography(artistId) {
|
|||
console.log('📦 Using cached discography');
|
||||
const cachedDiscography = artistsPageState.cache.discography[artistId];
|
||||
displayArtistDiscography(cachedDiscography);
|
||||
|
||||
// Load similar artists in parallel (don't wait)
|
||||
loadSimilarArtists(artistsPageState.selectedArtist?.name).catch(err => {
|
||||
console.error('❌ Error loading similar artists:', err);
|
||||
});
|
||||
|
||||
// Still check completion status for cached data
|
||||
await checkDiscographyCompletion(artistId, cachedDiscography);
|
||||
return;
|
||||
|
|
@ -15060,10 +15066,15 @@ async function loadArtistDiscography(artistId) {
|
|||
|
||||
// Display results
|
||||
displayArtistDiscography(discography);
|
||||
|
||||
|
||||
// Load similar artists and check completion in parallel (don't wait)
|
||||
loadSimilarArtists(artistsPageState.selectedArtist?.name).catch(err => {
|
||||
console.error('❌ Error loading similar artists:', err);
|
||||
});
|
||||
|
||||
// Check completion status for all albums and singles
|
||||
await checkDiscographyCompletion(artistId, discography);
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to load discography:', error);
|
||||
showDiscographyError(error.message);
|
||||
|
|
@ -15135,6 +15146,238 @@ 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 {
|
||||
// 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);
|
||||
|
||||
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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.error('❌ Error parsing stream message:', parseError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
// Create image container
|
||||
const imageContainer = document.createElement('div');
|
||||
imageContainer.className = 'similar-artist-bubble-image';
|
||||
|
||||
if (artist.image_url && artist.image_url.trim() !== '') {
|
||||
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>`;
|
||||
};
|
||||
|
||||
imageContainer.appendChild(img);
|
||||
} else {
|
||||
// No image - show fallback
|
||||
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);
|
||||
}
|
||||
|
||||
// TODO: Add click handler when functionality is ready
|
||||
// For now, just make it visually interactive
|
||||
bubble.addEventListener('click', () => {
|
||||
console.log(`🎵 Clicked similar artist: ${artist.name} (ID: ${artist.id})`);
|
||||
// Future: Navigate to this artist's detail page or trigger action
|
||||
});
|
||||
|
||||
return bubble;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore cached completion data without re-scanning the database
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -15410,3 +15410,291 @@ body {
|
|||
.fix-modal-results .error-message {
|
||||
color: rgba(239, 68, 68, 0.9);
|
||||
}
|
||||
|
||||
/* ===============================================
|
||||
Similar Artists Section - Artists Page
|
||||
=============================================== */
|
||||
|
||||
.similar-artists-section {
|
||||
margin-top: 60px;
|
||||
padding: 40px 0;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.similar-artists-header {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.similar-artists-title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
margin: 0 0 8px 0;
|
||||
font-family: 'SF Pro Display', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.similar-artists-subtitle {
|
||||
font-size: 15px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
font-family: 'SF Pro Text', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
}
|
||||
|
||||
/* Loading State */
|
||||
.similar-artists-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 60px 20px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.loading-spinner-small {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.1);
|
||||
border-top-color: #1db954;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
/* Error State */
|
||||
.similar-artists-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 60px 20px;
|
||||
color: rgba(239, 68, 68, 0.8);
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.similar-artists-error .error-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
/* Bubbles Container - Horizontal Scroll */
|
||||
.similar-artists-bubbles-container {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: calc((100% - (5 * 24px)) / 6); /* Each item is 1/6th of container width */
|
||||
gap: 24px;
|
||||
overflow-x: auto;
|
||||
padding: 20px 0 30px 0;
|
||||
scroll-behavior: smooth;
|
||||
|
||||
/* Custom scrollbar */
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.2) rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.similar-artists-bubbles-container::-webkit-scrollbar {
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.similar-artists-bubbles-container::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.similar-artists-bubbles-container::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 10px;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.similar-artists-bubbles-container::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* Artist Bubble Card */
|
||||
.similar-artist-bubble {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 20px 16px;
|
||||
border-radius: 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
/* Subtle background */
|
||||
background: linear-gradient(135deg,
|
||||
rgba(26, 26, 26, 0.6) 0%,
|
||||
rgba(18, 18, 18, 0.7) 100%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
|
||||
/* Elegant shadow */
|
||||
box-shadow:
|
||||
0 4px 12px rgba(0, 0, 0, 0.3),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
|
||||
/* Progressive load animation */
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
animation: fadeInUp 0.5s cubic-bezier(0.4, 0, 0.2, 1) forwards;
|
||||
}
|
||||
|
||||
/* Keyframe animation for bubbles */
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.similar-artist-bubble:hover {
|
||||
transform: translateY(-6px) scale(1.02);
|
||||
background: linear-gradient(135deg,
|
||||
rgba(32, 32, 32, 0.8) 0%,
|
||||
rgba(24, 24, 24, 0.9) 100%);
|
||||
border-color: rgba(29, 185, 84, 0.3);
|
||||
|
||||
box-shadow:
|
||||
0 8px 20px rgba(0, 0, 0, 0.4),
|
||||
0 0 20px rgba(29, 185, 84, 0.15),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
/* Circular Image Container */
|
||||
.similar-artist-bubble-image {
|
||||
position: relative;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
margin: 0 auto;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
|
||||
/* Premium border */
|
||||
border: 3px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow:
|
||||
0 4px 15px rgba(0, 0, 0, 0.4),
|
||||
inset 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.similar-artist-bubble:hover .similar-artist-bubble-image {
|
||||
border-color: rgba(29, 185, 84, 0.5);
|
||||
box-shadow:
|
||||
0 6px 20px rgba(0, 0, 0, 0.5),
|
||||
0 0 25px rgba(29, 185, 84, 0.2),
|
||||
inset 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.similar-artist-bubble-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Fallback for images that fail to load */
|
||||
.similar-artist-bubble-image-fallback {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg,
|
||||
rgba(29, 185, 84, 0.2) 0%,
|
||||
rgba(30, 215, 96, 0.15) 100%);
|
||||
font-size: 40px;
|
||||
}
|
||||
|
||||
/* Artist Name */
|
||||
.similar-artist-bubble-name {
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
line-height: 1.3;
|
||||
width: 100%;
|
||||
|
||||
/* Truncate long names */
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
font-family: 'SF Pro Text', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
}
|
||||
|
||||
.similar-artist-bubble:hover .similar-artist-bubble-name {
|
||||
color: #1db954;
|
||||
}
|
||||
|
||||
/* Genres (Optional - hidden by default for cleaner look) */
|
||||
.similar-artist-bubble-genres {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
|
||||
/* Truncate */
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
font-family: 'SF Pro Text', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 1400px) {
|
||||
.similar-artists-bubbles-container {
|
||||
grid-auto-columns: calc((100% - (4 * 24px)) / 5);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.similar-artists-bubbles-container {
|
||||
grid-auto-columns: calc((100% - (3 * 24px)) / 4);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.similar-artists-bubbles-container {
|
||||
grid-auto-columns: calc((100% - (2 * 24px)) / 3);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.similar-artists-section {
|
||||
margin-top: 40px;
|
||||
padding: 30px 0;
|
||||
}
|
||||
|
||||
.similar-artists-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.similar-artists-bubbles-container {
|
||||
grid-auto-columns: calc((100% - 16px) / 2);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.similar-artist-bubble {
|
||||
padding: 16px 12px;
|
||||
}
|
||||
|
||||
.similar-artist-bubble-image {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
.similar-artist-bubble-name {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue