From 0aa89504364f8969b8b43f9971f8179b0fe05db2 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Wed, 11 Mar 2026 07:52:58 -0700 Subject: [PATCH] fix build a playlist functionality and update the ui --- core/personalized_playlists.py | 211 +++++++++++++++---------- web_server.py | 39 ++++- webui/index.html | 47 ++++-- webui/static/script.js | 98 +++++++----- webui/static/style.css | 277 +++++++++++++++++++++++++-------- 5 files changed, 476 insertions(+), 196 deletions(-) diff --git a/core/personalized_playlists.py b/core/personalized_playlists.py index 7a7fa94a..5aa2bf94 100644 --- a/core/personalized_playlists.py +++ b/core/personalized_playlists.py @@ -860,7 +860,7 @@ class PersonalizedPlaylistsService: 4. Build playlist from tracks in those albums (max 50 tracks) Args: - seed_artist_ids: List of 1-5 Spotify artist IDs + seed_artist_ids: List of 1-5 artist IDs (Spotify or iTunes) playlist_size: Maximum tracks in final playlist (default: 50) Returns: @@ -871,19 +871,17 @@ class PersonalizedPlaylistsService: logger.error(f"Invalid seed artists count: {len(seed_artist_ids)}") return {'tracks': [], 'error': 'Must provide 1-5 seed artists'} - if not self.spotify_client or not self.spotify_client.is_authenticated(): - logger.error("Spotify client not available") - return {'tracks': [], 'error': 'Spotify not authenticated'} + use_spotify = self.spotify_client and self.spotify_client.sp + logger.info(f"Building custom playlist from {len(seed_artist_ids)} seed artists (source: {'spotify' if use_spotify else 'itunes'})") - logger.info(f"Building custom playlist from {len(seed_artist_ids)} seed artists") - - # Step 1: Get similar artists for each seed from database + # Step 1: Get similar artists for each seed all_similar_artists = [] - seen_artist_ids = set(seed_artist_ids) # Don't include seed artists themselves + seen_artist_ids = set(seed_artist_ids) for seed_artist_id in seed_artist_ids: try: - # Get similar artists from database (cached from MusicMap) + # Try database first (cached from MusicMap/watchlist scans) + db_results = [] with self.database._get_connection() as conn: cursor = conn.cursor() cursor.execute(""" @@ -893,23 +891,32 @@ class PersonalizedPlaylistsService: ORDER BY similarity_rank ASC LIMIT 10 """, (seed_artist_id,)) + db_results = cursor.fetchall() - rows = cursor.fetchall() - - for row in rows: + if db_results: + for row in db_results: artist_id = row['similar_artist_spotify_id'] artist_name = row['similar_artist_name'] - - if artist_id not in seen_artist_ids: - # Create artist-like object - all_similar_artists.append({ - 'id': artist_id, - 'name': artist_name - }) + if artist_id and artist_id not in seen_artist_ids: + all_similar_artists.append({'id': artist_id, 'name': artist_name}) seen_artist_ids.add(artist_id) - if len(all_similar_artists) >= 25: break + elif use_spotify: + # Fallback: fetch related artists from Spotify API + logger.info(f"No cached similar artists for {seed_artist_id}, trying Spotify related artists API") + try: + related = self.spotify_client.sp.artist_related_artists(seed_artist_id) + if related and 'artists' in related: + for artist in related['artists'][:10]: + artist_id = artist['id'] + if artist_id not in seen_artist_ids: + all_similar_artists.append({'id': artist_id, 'name': artist['name']}) + seen_artist_ids.add(artist_id) + if len(all_similar_artists) >= 25: + break + except Exception as e2: + logger.warning(f"Spotify related artists fallback failed for {seed_artist_id}: {e2}") if len(all_similar_artists) >= 25: break @@ -918,38 +925,49 @@ class PersonalizedPlaylistsService: logger.warning(f"Error getting similar artists for {seed_artist_id}: {e}") continue - logger.info(f"Found {len(all_similar_artists)} similar artists from database") + logger.info(f"Found {len(all_similar_artists)} similar artists") - if not all_similar_artists: - return {'tracks': [], 'error': 'No similar artists found'} + # Always include seed artists alongside similar artists + # so the playlist has tracks from both the selected and discovered artists + artists_for_albums = [{'id': sid, 'name': '', 'is_seed': True} for sid in seed_artist_ids] + for sa in all_similar_artists[:22]: # Cap similar to leave room for seeds + artists_for_albums.append({**sa, 'is_seed': False}) - # Limit to 25 similar artists - similar_artists_to_use = all_similar_artists[:25] - - # Step 2: Get albums from similar artists + # Step 2: Get albums from seed + similar artists all_albums = [] - for artist in similar_artists_to_use: - try: - albums = self.spotify_client.get_artist_albums( - artist['id'], - album_type='album,single', - limit=10 - ) - - if albums: - all_albums.extend(albums) - - import time - time.sleep(0.3) # Rate limiting - - except Exception as e: - logger.warning(f"Error getting albums for {artist['name']}: {e}") - continue + if use_spotify: + for artist in artists_for_albums: + try: + albums = self.spotify_client.get_artist_albums( + artist['id'], + album_type='album,single', + limit=10 + ) + if albums: + all_albums.extend(albums) + import time + time.sleep(0.3) + except Exception as e: + logger.warning(f"Error getting albums for {artist.get('name', artist['id'])}: {e}") + continue + else: + from core.itunes_client import iTunesClient + itunes = iTunesClient() + for artist in artists_for_albums: + try: + albums = itunes.get_artist_albums(artist['id'], limit=10) + if albums: + all_albums.extend(albums) + import time + time.sleep(0.3) + except Exception as e: + logger.warning(f"Error getting albums for {artist.get('name', artist['id'])}: {e}") + continue logger.info(f"Found {len(all_albums)} total albums") if not all_albums: - return {'tracks': [], 'error': 'No albums found'} + return {'tracks': [], 'error': 'No albums found for the selected artists'} # Step 3: Select 20 random albums random.shuffle(all_albums) @@ -959,40 +977,73 @@ class PersonalizedPlaylistsService: # Step 4: Build playlist from tracks in those albums all_tracks = [] - for album in selected_albums: - try: - album_data = self.spotify_client.get_album(album.id) - - if album_data and 'tracks' in album_data: - tracks = album_data['tracks'].get('items', []) - - for track in tracks: - if track['id']: - # Format in discovery pool format (for rendering + modal compatibility) - all_tracks.append({ - 'spotify_track_id': track['id'], - 'track_name': track['name'], - 'artist_name': ', '.join([a['name'] for a in track.get('artists', [])]), - 'album_name': album_data.get('name', 'Unknown'), - 'album_cover_url': album_data.get('images', [{}])[0].get('url') if album_data.get('images') else None, - 'duration_ms': track.get('duration_ms', 0), - 'popularity': album_data.get('popularity', 0), - # Also include Spotify format fields for modal - 'id': track['id'], - 'name': track['name'], - 'artists': [a['name'] for a in track.get('artists', [])], - 'album': { - 'name': album_data.get('name', 'Unknown'), - 'images': album_data.get('images', []) - } - }) - - import time - time.sleep(0.3) # Rate limiting - - except Exception as e: - logger.warning(f"Error getting tracks from album: {e}") - continue + if use_spotify: + for album in selected_albums: + try: + album_data = self.spotify_client.get_album(album.id) + if album_data and 'tracks' in album_data: + tracks = album_data['tracks'].get('items', []) + for track in tracks: + if track['id']: + all_tracks.append({ + 'spotify_track_id': track['id'], + 'track_name': track['name'], + 'artist_name': ', '.join([a['name'] for a in track.get('artists', [])]), + 'album_name': album_data.get('name', 'Unknown'), + 'album_cover_url': album_data.get('images', [{}])[0].get('url') if album_data.get('images') else None, + 'duration_ms': track.get('duration_ms', 0), + 'popularity': album_data.get('popularity', 0), + 'id': track['id'], + 'name': track['name'], + 'artists': [a['name'] for a in track.get('artists', [])], + 'album': { + 'name': album_data.get('name', 'Unknown'), + 'images': album_data.get('images', []) + } + }) + import time + time.sleep(0.3) + except Exception as e: + logger.warning(f"Error getting tracks from album: {e}") + continue + else: + from core.itunes_client import iTunesClient + itunes = iTunesClient() + for album in selected_albums: + try: + album_data = itunes.get_album(album.id, include_tracks=True) + if album_data and 'tracks' in album_data: + tracks = album_data['tracks'].get('items', []) + album_name = album_data.get('name', 'Unknown') + album_images = album_data.get('images', []) + album_cover = album_images[0].get('url') if album_images else None + for track in tracks: + track_id = track.get('id', '') + if track_id: + # iTunes artists are [{'name': '...'}] dicts + track_artists = track.get('artists', []) + artist_names = [a['name'] for a in track_artists] if isinstance(track_artists, list) and track_artists and isinstance(track_artists[0], dict) else (track_artists if isinstance(track_artists, list) else []) + all_tracks.append({ + 'spotify_track_id': track_id, + 'track_name': track.get('name', ''), + 'artist_name': ', '.join(artist_names) if artist_names else 'Unknown', + 'album_name': album_name, + 'album_cover_url': album_cover, + 'duration_ms': track.get('duration_ms', 0), + 'popularity': 0, + 'id': track_id, + 'name': track.get('name', ''), + 'artists': artist_names, + 'album': { + 'name': album_name, + 'images': album_images + } + }) + import time + time.sleep(0.3) + except Exception as e: + logger.warning(f"Error getting tracks from album: {e}") + continue logger.info(f"Collected {len(all_tracks)} total tracks") @@ -1012,7 +1063,7 @@ class PersonalizedPlaylistsService: 'tracks': final_tracks, 'metadata': { 'total_tracks': len(final_tracks), - 'similar_artists_count': len(similar_artists_to_use), + 'similar_artists_count': len(all_similar_artists), 'albums_count': len(selected_albums) } } diff --git a/web_server.py b/web_server.py index 801e7137..840f4d1b 100644 --- a/web_server.py +++ b/web_server.py @@ -28498,15 +28498,37 @@ def search_artists_for_playlist(): if hydrabase_worker and dev_mode_enabled: hydrabase_worker.enqueue(query, 'artists') - search_query = f'artist:{query}' if len(query.strip()) <= 4 else query - results = spotify_client.sp.search(q=search_query, type='artist', limit=10) - if results and 'artists' in results and 'items' in results['artists']: - for artist in results['artists']['items']: + # Try Spotify first, fall back to iTunes + if spotify_client.sp: + try: + search_query = f'artist:{query}' if len(query.strip()) <= 4 else query + results = spotify_client.sp.search(q=search_query, type='artist', limit=10) + if results and 'artists' in results and 'items' in results['artists']: + for artist in results['artists']['items']: + artists.append({ + 'id': artist['id'], + 'name': artist['name'], + 'image_url': artist['images'][0]['url'] if artist.get('images') else None + }) + except Exception as e: + logger.warning(f"Spotify artist search failed, falling back to iTunes: {e}") + + if not artists: + from core.itunes_client import iTunesClient + itunes = iTunesClient() + artist_objs = itunes.search_artists(query, limit=10) + for artist in artist_objs: + # iTunes artist search rarely returns images — grab from album art + image = artist.image_url + if not image: + image = itunes._get_artist_image_from_albums(artist.id) artists.append({ - 'id': artist['id'], - 'name': artist['name'], - 'image_url': artist['images'][0]['url'] if artist.get('images') else None + 'id': artist.id, + 'name': artist.name, + 'image_url': image }) + + if artists: # Re-rank: boost exact name matches to the top query_lower = query.lower().strip() artists.sort(key=lambda a: (0 if a['name'].lower().strip() == query_lower else 1)) @@ -28541,6 +28563,9 @@ def generate_custom_playlist(): playlist_size = int(data.get('playlist_size', 50)) result = service.build_custom_playlist(seed_artist_ids, playlist_size=playlist_size) + if result.get('error') and not result.get('tracks'): + return jsonify({"success": False, "error": result['error']}), 400 + return jsonify({ "success": True, "playlist": result diff --git a/webui/index.html b/webui/index.html index 01166a17..067b7cb5 100644 --- a/webui/index.html +++ b/webui/index.html @@ -3006,36 +3006,63 @@
-

🎨 Build a Playlist

+

+ Build a Playlist + ? +

Create a custom playlist from your favorite artists

+
+
+

How it works:

+
    +
  1. Search and select 1-5 seed artists you like
  2. +
  3. Hit Generate — the app finds similar artists, pulls their albums, and picks tracks at random
  4. +
  5. You get a fresh 50-track playlist mixing your picks with new discoveries
  6. +
+

Tip: The more seed artists you add, the more varied the playlist will be.

+
+
- +
+ + + +
-

Selected Artists:

+
+

Seed Artists

+ 0 / 5 +
-
No artists selected. Search and select 1-5 - artists.
+
+ + Search above to add seed artists +
diff --git a/webui/static/script.js b/webui/static/script.js index d15071e1..c7929a32 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -43707,36 +43707,53 @@ let buildPlaylistSearchTimeout = null; async function searchBuildPlaylistArtists() { const searchInput = document.getElementById('build-playlist-search'); const resultsContainer = document.getElementById('build-playlist-search-results'); + const spinner = document.getElementById('bp-search-spinner'); const query = searchInput.value.trim(); if (!query) { resultsContainer.innerHTML = ''; resultsContainer.style.display = 'none'; + if (spinner) spinner.style.display = 'none'; return; } // Debounce search clearTimeout(buildPlaylistSearchTimeout); buildPlaylistSearchTimeout = setTimeout(async () => { + if (spinner) spinner.style.display = 'flex'; try { const response = await fetch(`/api/discover/build-playlist/search-artists?query=${encodeURIComponent(query)}`); - if (!response.ok) return; - const data = await response.json(); + if (!response.ok) { + showToast(data.error || 'Search failed', 'error'); + return; + } if (!data.success || !data.artists || data.artists.length === 0) { - resultsContainer.innerHTML = '
No artists found
'; + resultsContainer.innerHTML = '
No artists found for "' + query.replace(/'; + resultsContainer.style.display = 'block'; + return; + } + + // Filter out already-selected artists + const selectedIds = new Set(buildPlaylistSelectedArtists.map(a => a.id)); + const filtered = data.artists.filter(a => !selectedIds.has(a.id)); + + if (filtered.length === 0) { + resultsContainer.innerHTML = '
All results already selected
'; resultsContainer.style.display = 'block'; return; } // Render search results let html = ''; - data.artists.forEach(artist => { + filtered.forEach(artist => { const imageUrl = artist.image_url || '/static/placeholder-album.png'; + const escapedName = artist.name.replace(/'/g, "\\'").replace(/"/g, '"'); html += ` -
- ${artist.name} - ${artist.name} +
+ ${artist.name} + ${artist.name} + + Add
`; }); @@ -43746,31 +43763,28 @@ async function searchBuildPlaylistArtists() { } catch (error) { console.error('Error searching artists:', error); + } finally { + if (spinner) spinner.style.display = 'none'; } - }, 300); + }, 400); } function addBuildPlaylistArtist(artistId, artistName, imageUrl) { - // Check if already selected if (buildPlaylistSelectedArtists.some(a => a.id === artistId)) { - alert('Artist already selected'); + showToast('Artist already selected', 'warning'); return; } - - // Check maximum limit if (buildPlaylistSelectedArtists.length >= 5) { - alert('Maximum 5 artists allowed'); + showToast('Maximum 5 seed artists', 'warning'); return; } - // Add to selected artists buildPlaylistSelectedArtists.push({ id: artistId, name: artistName, image_url: imageUrl }); - // Update UI renderBuildPlaylistSelectedArtists(); // Clear search @@ -43787,35 +43801,42 @@ function removeBuildPlaylistArtist(artistId) { function renderBuildPlaylistSelectedArtists() { const container = document.getElementById('build-playlist-selected-artists'); const generateBtn = document.getElementById('build-playlist-generate-btn'); + const counter = document.getElementById('bp-selected-counter'); + const count = buildPlaylistSelectedArtists.length; - if (buildPlaylistSelectedArtists.length === 0) { - container.innerHTML = '
No artists selected. Search and select 1-5 artists.
'; + if (counter) counter.textContent = `${count} / 5`; + + if (count === 0) { + container.innerHTML = ` +
+ + Search above to add seed artists +
`; generateBtn.disabled = true; - generateBtn.style.opacity = '0.5'; return; } let html = ''; buildPlaylistSelectedArtists.forEach(artist => { + const escapedId = artist.id.replace(/'/g, "\\'"); html += `
- ${artist.name} + ${artist.name} ${artist.name} - +
`; }); container.innerHTML = html; generateBtn.disabled = false; - generateBtn.style.opacity = '1'; } let buildPlaylistTracks = []; async function generateBuildPlaylist() { if (buildPlaylistSelectedArtists.length === 0) { - alert('Please select at least 1 artist'); + showToast('Please select at least 1 artist', 'warning'); return; } @@ -43827,9 +43848,8 @@ async function generateBuildPlaylist() { const titleEl = document.getElementById('build-playlist-results-title'); const subtitleEl = document.getElementById('build-playlist-results-subtitle'); - // Show loading + // Show loading, hide search area generateBtn.disabled = true; - generateBtn.style.opacity = '0.5'; loadingIndicator.style.display = 'flex'; resultsWrapper.style.display = 'none'; resultsContainer.innerHTML = ''; @@ -43845,13 +43865,12 @@ async function generateBuildPlaylist() { }) }); - if (!response.ok) { - throw new Error('Failed to generate playlist'); - } - const data = await response.json(); - if (!data.success || !data.playlist || !data.playlist.tracks) { - throw new Error('Invalid playlist data'); + if (!response.ok || !data.success) { + throw new Error(data.error || 'Failed to generate playlist'); + } + if (!data.playlist || !data.playlist.tracks || data.playlist.tracks.length === 0) { + throw new Error(data.playlist?.error || 'No tracks found. Try different seed artists.'); } // Store tracks globally @@ -43866,9 +43885,18 @@ async function generateBuildPlaylist() { const metadata = data.playlist.metadata; metadataDisplay.innerHTML = ` `; @@ -43880,11 +43908,11 @@ async function generateBuildPlaylist() { } catch (error) { console.error('Error generating playlist:', error); - resultsContainer.innerHTML = '
Failed to generate playlist. Please try again.
'; + resultsWrapper.style.display = 'none'; + showToast(error.message || 'Failed to generate playlist', 'error'); } finally { loadingIndicator.style.display = 'none'; generateBtn.disabled = false; - generateBtn.style.opacity = '1'; } } diff --git a/webui/static/style.css b/webui/static/style.css index 76bf1379..8f57fa3b 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -23956,65 +23956,150 @@ body { BUILD A PLAYLIST STYLES =============================== */ +/* ---- Build a Playlist ---- */ + +.bp-info-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 50%; + background: rgba(var(--accent-rgb), 0.15); + color: rgb(var(--accent-rgb)); + font-size: 12px; + font-weight: 700; + cursor: pointer; + margin-left: 8px; + vertical-align: middle; + transition: background 0.2s; + line-height: 1; +} +.bp-info-toggle:hover { + background: rgba(var(--accent-rgb), 0.3); +} + +.bp-info-panel { + max-height: 0; + overflow: hidden; + transition: max-height 0.3s ease, padding 0.3s ease; + background: rgba(var(--accent-rgb), 0.06); + border-radius: 10px; + margin-bottom: 0; +} +.bp-info-panel.visible { + max-height: 260px; + padding: 16px 20px; + margin-bottom: 16px; + border: 1px solid rgba(var(--accent-rgb), 0.15); +} +.bp-info-content p { + color: #ccc; + font-size: 13px; + margin: 0 0 8px 0; +} +.bp-info-content p strong { + color: #fff; +} +.bp-info-content ol { + margin: 0 0 8px 0; + padding-left: 20px; + color: #bbb; + font-size: 13px; + line-height: 1.7; +} +.bp-info-note { + color: #888 !important; + font-size: 12px !important; + font-style: italic; + margin-bottom: 0 !important; +} + .build-playlist-container { background: #1a1a1a; border-radius: 12px; padding: 24px; - margin-top: 20px; + margin-top: 16px; } +/* -- Search Input -- */ .build-playlist-search-section { position: relative; - margin-bottom: 24px; + margin-bottom: 20px; +} + +.bp-search-input-wrapper { + position: relative; + display: flex; + align-items: center; +} +.bp-search-icon { + position: absolute; + left: 14px; + width: 16px; + height: 16px; + color: #666; + pointer-events: none; +} +.bp-search-spinner { + position: absolute; + right: 14px; + display: flex; + align-items: center; } #build-playlist-search { width: 100%; - padding: 12px 16px; + padding: 12px 42px 12px 40px; background: #2a2a2a; border: 1px solid #3a3a3a; - border-radius: 8px; + border-radius: 10px; color: #fff; font-size: 14px; outline: none; - transition: border-color 0.2s ease; + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} +#build-playlist-search::placeholder { + color: #666; } - #build-playlist-search:focus { border-color: rgb(var(--accent-rgb)); + box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.1); } +/* -- Search Results Dropdown -- */ .build-playlist-search-results { position: absolute; - top: 100%; + top: calc(100% + 4px); left: 0; right: 0; - background: #2a2a2a; + background: #222; border: 1px solid #3a3a3a; - border-radius: 8px; - margin-top: 8px; - max-height: 300px; + border-radius: 10px; + max-height: 320px; overflow-y: auto; z-index: 1000; display: none; + box-shadow: 0 8px 24px rgba(0,0,0,0.4); } .build-playlist-search-result { display: flex; align-items: center; gap: 12px; - padding: 12px; + padding: 10px 14px; cursor: pointer; - transition: background-color 0.2s ease; - border-bottom: 1px solid #3a3a3a; + transition: background-color 0.15s ease; + border-bottom: 1px solid #2a2a2a; } - .build-playlist-search-result:last-child { border-bottom: none; } - .build-playlist-search-result:hover { - background-color: #3a3a3a; + background-color: rgba(var(--accent-rgb), 0.08); +} +.build-playlist-search-result:hover .bp-result-add { + opacity: 1; } .build-playlist-search-result img { @@ -24022,35 +24107,72 @@ body { height: 40px; border-radius: 50%; object-fit: cover; + flex-shrink: 0; + background: #333; } -.build-playlist-search-result span { +.bp-result-name { color: #fff; font-size: 14px; + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.bp-result-add { + color: rgb(var(--accent-rgb)); + font-size: 12px; + font-weight: 600; + opacity: 0; + transition: opacity 0.15s; + flex-shrink: 0; } .build-playlist-no-results { - padding: 16px; + padding: 20px 16px; text-align: center; - color: #999; - font-size: 14px; + color: #777; + font-size: 13px; } +/* -- Selected Artists -- */ .build-playlist-selected-section { - margin-bottom: 24px; + margin-bottom: 20px; } -.build-playlist-selected-section h3 { +.bp-selected-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 10px; +} +.bp-selected-header h3 { color: #fff; - font-size: 16px; - margin: 0 0 12px 0; + font-size: 14px; + font-weight: 600; + margin: 0; +} +.bp-selected-counter { + color: #666; + font-size: 12px; + font-weight: 500; } .build-playlist-selected-artists { display: flex; flex-wrap: wrap; - gap: 12px; - min-height: 60px; + gap: 10px; + min-height: 56px; + padding: 12px; + background: #151515; + border: 1px dashed #333; + border-radius: 10px; + transition: border-color 0.2s; +} +.build-playlist-selected-artists:has(.build-playlist-selected-artist) { + border-style: solid; + border-color: #3a3a3a; } .build-playlist-selected-artist { @@ -24059,74 +24181,91 @@ body { gap: 8px; background: #2a2a2a; border: 1px solid #3a3a3a; - border-radius: 8px; - padding: 8px 12px; + border-radius: 20px; + padding: 6px 10px 6px 6px; + transition: border-color 0.2s; +} +.build-playlist-selected-artist:hover { + border-color: #555; } .build-playlist-selected-artist img { - width: 32px; - height: 32px; + width: 28px; + height: 28px; border-radius: 50%; object-fit: cover; + flex-shrink: 0; + background: #333; } .build-playlist-selected-artist span { color: #fff; - font-size: 14px; + font-size: 13px; + font-weight: 500; } .build-playlist-remove-artist { background: none; border: none; - color: #ff4444; - font-size: 20px; + color: #777; + font-size: 16px; cursor: pointer; padding: 0; - margin-left: 4px; - width: 20px; - height: 20px; + margin-left: 2px; + width: 18px; + height: 18px; display: flex; align-items: center; justify-content: center; - transition: color 0.2s ease; + border-radius: 50%; + transition: all 0.15s ease; + line-height: 1; } - .build-playlist-remove-artist:hover { - color: #ff6666; + color: #ff5555; + background: rgba(255,85,85,0.1); } .build-playlist-no-selection { - color: #999; - font-size: 14px; - padding: 16px; + color: #555; + font-size: 13px; + padding: 8px; text-align: center; + width: 100%; + display: flex; + flex-direction: column; + align-items: center; } +/* -- Generate Button & Loading -- */ .build-playlist-actions { display: flex; align-items: center; gap: 16px; - margin-bottom: 24px; + margin-bottom: 20px; } .build-playlist-generate-btn { + display: inline-flex; + align-items: center; + gap: 8px; background: rgb(var(--accent-rgb)); color: #fff; border: none; - border-radius: 8px; + border-radius: 10px; padding: 12px 24px; font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.2s ease; } - .build-playlist-generate-btn:not(:disabled):hover { - background: rgb(var(--accent-light-rgb)); - transform: scale(1.02); + filter: brightness(1.15); + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(var(--accent-rgb), 0.3); } - .build-playlist-generate-btn:disabled { + opacity: 0.4; cursor: not-allowed; } @@ -24135,31 +24274,41 @@ body { align-items: center; gap: 12px; } - -.build-playlist-loading p { +.build-playlist-loading span { color: #999; - font-size: 14px; - margin: 0; + font-size: 13px; } +/* -- Metadata Stats -- */ .build-playlist-metadata { - background: #2a2a2a; - border-radius: 8px; - padding: 16px; - margin-bottom: 16px; display: grid; grid-template-columns: repeat(3, 1fr); - gap: 16px; + gap: 12px; + margin-bottom: 16px; } -.build-playlist-metadata p { - color: #fff; - font-size: 13px; - margin: 0; +.bp-meta-stat { + background: #222; + border-radius: 10px; + padding: 14px 16px; + text-align: center; + border: 1px solid #2a2a2a; } - -.build-playlist-metadata strong { +.bp-meta-value { + display: block; color: rgb(var(--accent-rgb)); + font-size: 22px; + font-weight: 700; + line-height: 1.2; +} +.bp-meta-label { + display: block; + color: #888; + font-size: 11px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-top: 4px; } /* =======================