fix build a playlist functionality and update the ui
This commit is contained in:
parent
e5450d9f89
commit
0aa8950436
5 changed files with 476 additions and 196 deletions
|
|
@ -860,7 +860,7 @@ class PersonalizedPlaylistsService:
|
||||||
4. Build playlist from tracks in those albums (max 50 tracks)
|
4. Build playlist from tracks in those albums (max 50 tracks)
|
||||||
|
|
||||||
Args:
|
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)
|
playlist_size: Maximum tracks in final playlist (default: 50)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
|
|
@ -871,19 +871,17 @@ class PersonalizedPlaylistsService:
|
||||||
logger.error(f"Invalid seed artists count: {len(seed_artist_ids)}")
|
logger.error(f"Invalid seed artists count: {len(seed_artist_ids)}")
|
||||||
return {'tracks': [], 'error': 'Must provide 1-5 seed artists'}
|
return {'tracks': [], 'error': 'Must provide 1-5 seed artists'}
|
||||||
|
|
||||||
if not self.spotify_client or not self.spotify_client.is_authenticated():
|
use_spotify = self.spotify_client and self.spotify_client.sp
|
||||||
logger.error("Spotify client not available")
|
logger.info(f"Building custom playlist from {len(seed_artist_ids)} seed artists (source: {'spotify' if use_spotify else 'itunes'})")
|
||||||
return {'tracks': [], 'error': 'Spotify not authenticated'}
|
|
||||||
|
|
||||||
logger.info(f"Building custom playlist from {len(seed_artist_ids)} seed artists")
|
# Step 1: Get similar artists for each seed
|
||||||
|
|
||||||
# Step 1: Get similar artists for each seed from database
|
|
||||||
all_similar_artists = []
|
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:
|
for seed_artist_id in seed_artist_ids:
|
||||||
try:
|
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:
|
with self.database._get_connection() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
|
|
@ -893,23 +891,32 @@ class PersonalizedPlaylistsService:
|
||||||
ORDER BY similarity_rank ASC
|
ORDER BY similarity_rank ASC
|
||||||
LIMIT 10
|
LIMIT 10
|
||||||
""", (seed_artist_id,))
|
""", (seed_artist_id,))
|
||||||
|
db_results = cursor.fetchall()
|
||||||
|
|
||||||
rows = cursor.fetchall()
|
if db_results:
|
||||||
|
for row in db_results:
|
||||||
for row in rows:
|
|
||||||
artist_id = row['similar_artist_spotify_id']
|
artist_id = row['similar_artist_spotify_id']
|
||||||
artist_name = row['similar_artist_name']
|
artist_name = row['similar_artist_name']
|
||||||
|
if artist_id and artist_id not in seen_artist_ids:
|
||||||
if artist_id not in seen_artist_ids:
|
all_similar_artists.append({'id': artist_id, 'name': artist_name})
|
||||||
# Create artist-like object
|
|
||||||
all_similar_artists.append({
|
|
||||||
'id': artist_id,
|
|
||||||
'name': artist_name
|
|
||||||
})
|
|
||||||
seen_artist_ids.add(artist_id)
|
seen_artist_ids.add(artist_id)
|
||||||
|
|
||||||
if len(all_similar_artists) >= 25:
|
if len(all_similar_artists) >= 25:
|
||||||
break
|
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:
|
if len(all_similar_artists) >= 25:
|
||||||
break
|
break
|
||||||
|
|
@ -918,38 +925,49 @@ class PersonalizedPlaylistsService:
|
||||||
logger.warning(f"Error getting similar artists for {seed_artist_id}: {e}")
|
logger.warning(f"Error getting similar artists for {seed_artist_id}: {e}")
|
||||||
continue
|
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:
|
# Always include seed artists alongside similar artists
|
||||||
return {'tracks': [], 'error': 'No similar artists found'}
|
# 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
|
# Step 2: Get albums from seed + similar artists
|
||||||
similar_artists_to_use = all_similar_artists[:25]
|
|
||||||
|
|
||||||
# Step 2: Get albums from similar artists
|
|
||||||
all_albums = []
|
all_albums = []
|
||||||
for artist in similar_artists_to_use:
|
if use_spotify:
|
||||||
try:
|
for artist in artists_for_albums:
|
||||||
albums = self.spotify_client.get_artist_albums(
|
try:
|
||||||
artist['id'],
|
albums = self.spotify_client.get_artist_albums(
|
||||||
album_type='album,single',
|
artist['id'],
|
||||||
limit=10
|
album_type='album,single',
|
||||||
)
|
limit=10
|
||||||
|
)
|
||||||
if albums:
|
if albums:
|
||||||
all_albums.extend(albums)
|
all_albums.extend(albums)
|
||||||
|
import time
|
||||||
import time
|
time.sleep(0.3)
|
||||||
time.sleep(0.3) # Rate limiting
|
except Exception as e:
|
||||||
|
logger.warning(f"Error getting albums for {artist.get('name', artist['id'])}: {e}")
|
||||||
except Exception as e:
|
continue
|
||||||
logger.warning(f"Error getting albums for {artist['name']}: {e}")
|
else:
|
||||||
continue
|
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")
|
logger.info(f"Found {len(all_albums)} total albums")
|
||||||
|
|
||||||
if not all_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
|
# Step 3: Select 20 random albums
|
||||||
random.shuffle(all_albums)
|
random.shuffle(all_albums)
|
||||||
|
|
@ -959,40 +977,73 @@ class PersonalizedPlaylistsService:
|
||||||
|
|
||||||
# Step 4: Build playlist from tracks in those albums
|
# Step 4: Build playlist from tracks in those albums
|
||||||
all_tracks = []
|
all_tracks = []
|
||||||
for album in selected_albums:
|
if use_spotify:
|
||||||
try:
|
for album in selected_albums:
|
||||||
album_data = self.spotify_client.get_album(album.id)
|
try:
|
||||||
|
album_data = self.spotify_client.get_album(album.id)
|
||||||
if album_data and 'tracks' in album_data:
|
if album_data and 'tracks' in album_data:
|
||||||
tracks = album_data['tracks'].get('items', [])
|
tracks = album_data['tracks'].get('items', [])
|
||||||
|
for track in tracks:
|
||||||
for track in tracks:
|
if track['id']:
|
||||||
if track['id']:
|
all_tracks.append({
|
||||||
# Format in discovery pool format (for rendering + modal compatibility)
|
'spotify_track_id': track['id'],
|
||||||
all_tracks.append({
|
'track_name': track['name'],
|
||||||
'spotify_track_id': track['id'],
|
'artist_name': ', '.join([a['name'] for a in track.get('artists', [])]),
|
||||||
'track_name': track['name'],
|
'album_name': album_data.get('name', 'Unknown'),
|
||||||
'artist_name': ', '.join([a['name'] for a in track.get('artists', [])]),
|
'album_cover_url': album_data.get('images', [{}])[0].get('url') if album_data.get('images') else None,
|
||||||
'album_name': album_data.get('name', 'Unknown'),
|
'duration_ms': track.get('duration_ms', 0),
|
||||||
'album_cover_url': album_data.get('images', [{}])[0].get('url') if album_data.get('images') else None,
|
'popularity': album_data.get('popularity', 0),
|
||||||
'duration_ms': track.get('duration_ms', 0),
|
'id': track['id'],
|
||||||
'popularity': album_data.get('popularity', 0),
|
'name': track['name'],
|
||||||
# Also include Spotify format fields for modal
|
'artists': [a['name'] for a in track.get('artists', [])],
|
||||||
'id': track['id'],
|
'album': {
|
||||||
'name': track['name'],
|
'name': album_data.get('name', 'Unknown'),
|
||||||
'artists': [a['name'] for a in track.get('artists', [])],
|
'images': album_data.get('images', [])
|
||||||
'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}")
|
||||||
import time
|
continue
|
||||||
time.sleep(0.3) # Rate limiting
|
else:
|
||||||
|
from core.itunes_client import iTunesClient
|
||||||
except Exception as e:
|
itunes = iTunesClient()
|
||||||
logger.warning(f"Error getting tracks from album: {e}")
|
for album in selected_albums:
|
||||||
continue
|
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")
|
logger.info(f"Collected {len(all_tracks)} total tracks")
|
||||||
|
|
||||||
|
|
@ -1012,7 +1063,7 @@ class PersonalizedPlaylistsService:
|
||||||
'tracks': final_tracks,
|
'tracks': final_tracks,
|
||||||
'metadata': {
|
'metadata': {
|
||||||
'total_tracks': len(final_tracks),
|
'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)
|
'albums_count': len(selected_albums)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28498,15 +28498,37 @@ def search_artists_for_playlist():
|
||||||
if hydrabase_worker and dev_mode_enabled:
|
if hydrabase_worker and dev_mode_enabled:
|
||||||
hydrabase_worker.enqueue(query, 'artists')
|
hydrabase_worker.enqueue(query, 'artists')
|
||||||
|
|
||||||
search_query = f'artist:{query}' if len(query.strip()) <= 4 else query
|
# Try Spotify first, fall back to iTunes
|
||||||
results = spotify_client.sp.search(q=search_query, type='artist', limit=10)
|
if spotify_client.sp:
|
||||||
if results and 'artists' in results and 'items' in results['artists']:
|
try:
|
||||||
for artist in results['artists']['items']:
|
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({
|
artists.append({
|
||||||
'id': artist['id'],
|
'id': artist.id,
|
||||||
'name': artist['name'],
|
'name': artist.name,
|
||||||
'image_url': artist['images'][0]['url'] if artist.get('images') else None
|
'image_url': image
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if artists:
|
||||||
# Re-rank: boost exact name matches to the top
|
# Re-rank: boost exact name matches to the top
|
||||||
query_lower = query.lower().strip()
|
query_lower = query.lower().strip()
|
||||||
artists.sort(key=lambda a: (0 if a['name'].lower().strip() == query_lower else 1))
|
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))
|
playlist_size = int(data.get('playlist_size', 50))
|
||||||
result = service.build_custom_playlist(seed_artist_ids, playlist_size=playlist_size)
|
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({
|
return jsonify({
|
||||||
"success": True,
|
"success": True,
|
||||||
"playlist": result
|
"playlist": result
|
||||||
|
|
|
||||||
|
|
@ -3006,36 +3006,63 @@
|
||||||
<!-- Build a Playlist Section -->
|
<!-- Build a Playlist Section -->
|
||||||
<div class="discover-section">
|
<div class="discover-section">
|
||||||
<div class="discover-section-header">
|
<div class="discover-section-header">
|
||||||
<h2 class="discover-section-title">🎨 Build a Playlist</h2>
|
<h2 class="discover-section-title">
|
||||||
|
Build a Playlist
|
||||||
|
<span class="bp-info-toggle" onclick="document.getElementById('bp-info-panel').classList.toggle('visible')" title="How it works">?</span>
|
||||||
|
</h2>
|
||||||
<p class="discover-section-subtitle">Create a custom playlist from your favorite artists</p>
|
<p class="discover-section-subtitle">Create a custom playlist from your favorite artists</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="bp-info-panel" id="bp-info-panel">
|
||||||
|
<div class="bp-info-content">
|
||||||
|
<p><strong>How it works:</strong></p>
|
||||||
|
<ol>
|
||||||
|
<li>Search and select 1-5 seed artists you like</li>
|
||||||
|
<li>Hit Generate — the app finds similar artists, pulls their albums, and picks tracks at random</li>
|
||||||
|
<li>You get a fresh 50-track playlist mixing your picks with new discoveries</li>
|
||||||
|
</ol>
|
||||||
|
<p class="bp-info-note">Tip: The more seed artists you add, the more varied the playlist will be.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="build-playlist-container">
|
<div class="build-playlist-container">
|
||||||
<!-- Artist Search -->
|
<!-- Artist Search -->
|
||||||
<div class="build-playlist-search-section">
|
<div class="build-playlist-search-section">
|
||||||
<input type="text" id="build-playlist-search"
|
<div class="bp-search-input-wrapper">
|
||||||
placeholder="Search for artists (1-5 artists)..."
|
<svg class="bp-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
|
||||||
oninput="searchBuildPlaylistArtists()" />
|
<input type="text" id="build-playlist-search"
|
||||||
|
placeholder="Search for an artist..."
|
||||||
|
oninput="searchBuildPlaylistArtists()"
|
||||||
|
autocomplete="off" />
|
||||||
|
<div class="bp-search-spinner" id="bp-search-spinner" style="display: none;">
|
||||||
|
<div class="loading-spinner" style="width: 18px; height: 18px; border-width: 2px;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div id="build-playlist-search-results" class="build-playlist-search-results"></div>
|
<div id="build-playlist-search-results" class="build-playlist-search-results"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Selected Artists -->
|
<!-- Selected Artists -->
|
||||||
<div class="build-playlist-selected-section">
|
<div class="build-playlist-selected-section">
|
||||||
<h3>Selected Artists:</h3>
|
<div class="bp-selected-header">
|
||||||
|
<h3>Seed Artists</h3>
|
||||||
|
<span class="bp-selected-counter" id="bp-selected-counter">0 / 5</span>
|
||||||
|
</div>
|
||||||
<div id="build-playlist-selected-artists" class="build-playlist-selected-artists">
|
<div id="build-playlist-selected-artists" class="build-playlist-selected-artists">
|
||||||
<div class="build-playlist-no-selection">No artists selected. Search and select 1-5
|
<div class="build-playlist-no-selection">
|
||||||
artists.</div>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="width: 32px; height: 32px; opacity: 0.4; margin-bottom: 8px;"><path d="M12 4.5v15m7.5-7.5h-15"/></svg>
|
||||||
|
<span>Search above to add seed artists</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Generate Button -->
|
<!-- Generate Button -->
|
||||||
<div class="build-playlist-actions">
|
<div class="build-playlist-actions">
|
||||||
<button id="build-playlist-generate-btn" class="build-playlist-generate-btn"
|
<button id="build-playlist-generate-btn" class="build-playlist-generate-btn"
|
||||||
onclick="generateBuildPlaylist()" disabled style="opacity: 0.5;">
|
onclick="generateBuildPlaylist()" disabled>
|
||||||
Generate Playlist (50 tracks)
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 18px; height: 18px;"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||||
|
Generate Playlist
|
||||||
</button>
|
</button>
|
||||||
<div id="build-playlist-loading" class="build-playlist-loading" style="display: none;">
|
<div id="build-playlist-loading" class="build-playlist-loading" style="display: none;">
|
||||||
<div class="loading-spinner"></div>
|
<div class="loading-spinner"></div>
|
||||||
<p>Building your playlist...</p>
|
<span>Finding similar artists and building your playlist...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43707,36 +43707,53 @@ let buildPlaylistSearchTimeout = null;
|
||||||
async function searchBuildPlaylistArtists() {
|
async function searchBuildPlaylistArtists() {
|
||||||
const searchInput = document.getElementById('build-playlist-search');
|
const searchInput = document.getElementById('build-playlist-search');
|
||||||
const resultsContainer = document.getElementById('build-playlist-search-results');
|
const resultsContainer = document.getElementById('build-playlist-search-results');
|
||||||
|
const spinner = document.getElementById('bp-search-spinner');
|
||||||
const query = searchInput.value.trim();
|
const query = searchInput.value.trim();
|
||||||
|
|
||||||
if (!query) {
|
if (!query) {
|
||||||
resultsContainer.innerHTML = '';
|
resultsContainer.innerHTML = '';
|
||||||
resultsContainer.style.display = 'none';
|
resultsContainer.style.display = 'none';
|
||||||
|
if (spinner) spinner.style.display = 'none';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debounce search
|
// Debounce search
|
||||||
clearTimeout(buildPlaylistSearchTimeout);
|
clearTimeout(buildPlaylistSearchTimeout);
|
||||||
buildPlaylistSearchTimeout = setTimeout(async () => {
|
buildPlaylistSearchTimeout = setTimeout(async () => {
|
||||||
|
if (spinner) spinner.style.display = 'flex';
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/discover/build-playlist/search-artists?query=${encodeURIComponent(query)}`);
|
const response = await fetch(`/api/discover/build-playlist/search-artists?query=${encodeURIComponent(query)}`);
|
||||||
if (!response.ok) return;
|
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
showToast(data.error || 'Search failed', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!data.success || !data.artists || data.artists.length === 0) {
|
if (!data.success || !data.artists || data.artists.length === 0) {
|
||||||
resultsContainer.innerHTML = '<div class="build-playlist-no-results">No artists found</div>';
|
resultsContainer.innerHTML = '<div class="build-playlist-no-results">No artists found for "' + query.replace(/</g, '<') + '"</div>';
|
||||||
|
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 = '<div class="build-playlist-no-results">All results already selected</div>';
|
||||||
resultsContainer.style.display = 'block';
|
resultsContainer.style.display = 'block';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render search results
|
// Render search results
|
||||||
let html = '';
|
let html = '';
|
||||||
data.artists.forEach(artist => {
|
filtered.forEach(artist => {
|
||||||
const imageUrl = artist.image_url || '/static/placeholder-album.png';
|
const imageUrl = artist.image_url || '/static/placeholder-album.png';
|
||||||
|
const escapedName = artist.name.replace(/'/g, "\\'").replace(/"/g, '"');
|
||||||
html += `
|
html += `
|
||||||
<div class="build-playlist-search-result" onclick="addBuildPlaylistArtist('${artist.id}', '${artist.name.replace(/'/g, "\\'")}', '${imageUrl}')">
|
<div class="build-playlist-search-result" onclick="addBuildPlaylistArtist('${artist.id}', '${escapedName}', '${imageUrl}')">
|
||||||
<img src="${imageUrl}" alt="${artist.name}" loading="lazy">
|
<img src="${imageUrl}" alt="${artist.name}" loading="lazy" onerror="this.src='/static/placeholder-album.png'">
|
||||||
<span>${artist.name}</span>
|
<span class="bp-result-name">${artist.name}</span>
|
||||||
|
<span class="bp-result-add">+ Add</span>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
});
|
});
|
||||||
|
|
@ -43746,31 +43763,28 @@ async function searchBuildPlaylistArtists() {
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error searching artists:', error);
|
console.error('Error searching artists:', error);
|
||||||
|
} finally {
|
||||||
|
if (spinner) spinner.style.display = 'none';
|
||||||
}
|
}
|
||||||
}, 300);
|
}, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
function addBuildPlaylistArtist(artistId, artistName, imageUrl) {
|
function addBuildPlaylistArtist(artistId, artistName, imageUrl) {
|
||||||
// Check if already selected
|
|
||||||
if (buildPlaylistSelectedArtists.some(a => a.id === artistId)) {
|
if (buildPlaylistSelectedArtists.some(a => a.id === artistId)) {
|
||||||
alert('Artist already selected');
|
showToast('Artist already selected', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check maximum limit
|
|
||||||
if (buildPlaylistSelectedArtists.length >= 5) {
|
if (buildPlaylistSelectedArtists.length >= 5) {
|
||||||
alert('Maximum 5 artists allowed');
|
showToast('Maximum 5 seed artists', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add to selected artists
|
|
||||||
buildPlaylistSelectedArtists.push({
|
buildPlaylistSelectedArtists.push({
|
||||||
id: artistId,
|
id: artistId,
|
||||||
name: artistName,
|
name: artistName,
|
||||||
image_url: imageUrl
|
image_url: imageUrl
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update UI
|
|
||||||
renderBuildPlaylistSelectedArtists();
|
renderBuildPlaylistSelectedArtists();
|
||||||
|
|
||||||
// Clear search
|
// Clear search
|
||||||
|
|
@ -43787,35 +43801,42 @@ function removeBuildPlaylistArtist(artistId) {
|
||||||
function renderBuildPlaylistSelectedArtists() {
|
function renderBuildPlaylistSelectedArtists() {
|
||||||
const container = document.getElementById('build-playlist-selected-artists');
|
const container = document.getElementById('build-playlist-selected-artists');
|
||||||
const generateBtn = document.getElementById('build-playlist-generate-btn');
|
const generateBtn = document.getElementById('build-playlist-generate-btn');
|
||||||
|
const counter = document.getElementById('bp-selected-counter');
|
||||||
|
const count = buildPlaylistSelectedArtists.length;
|
||||||
|
|
||||||
if (buildPlaylistSelectedArtists.length === 0) {
|
if (counter) counter.textContent = `${count} / 5`;
|
||||||
container.innerHTML = '<div class="build-playlist-no-selection">No artists selected. Search and select 1-5 artists.</div>';
|
|
||||||
|
if (count === 0) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="build-playlist-no-selection">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="width: 32px; height: 32px; opacity: 0.4; margin-bottom: 8px;"><path d="M12 4.5v15m7.5-7.5h-15"/></svg>
|
||||||
|
<span>Search above to add seed artists</span>
|
||||||
|
</div>`;
|
||||||
generateBtn.disabled = true;
|
generateBtn.disabled = true;
|
||||||
generateBtn.style.opacity = '0.5';
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let html = '';
|
let html = '';
|
||||||
buildPlaylistSelectedArtists.forEach(artist => {
|
buildPlaylistSelectedArtists.forEach(artist => {
|
||||||
|
const escapedId = artist.id.replace(/'/g, "\\'");
|
||||||
html += `
|
html += `
|
||||||
<div class="build-playlist-selected-artist">
|
<div class="build-playlist-selected-artist">
|
||||||
<img src="${artist.image_url}" alt="${artist.name}" loading="lazy">
|
<img src="${artist.image_url || '/static/placeholder-album.png'}" alt="${artist.name}" loading="lazy" onerror="this.src='/static/placeholder-album.png'">
|
||||||
<span>${artist.name}</span>
|
<span>${artist.name}</span>
|
||||||
<button onclick="removeBuildPlaylistArtist('${artist.id}')" class="build-playlist-remove-artist">×</button>
|
<button onclick="removeBuildPlaylistArtist('${escapedId}')" class="build-playlist-remove-artist" title="Remove">×</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
});
|
});
|
||||||
|
|
||||||
container.innerHTML = html;
|
container.innerHTML = html;
|
||||||
generateBtn.disabled = false;
|
generateBtn.disabled = false;
|
||||||
generateBtn.style.opacity = '1';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let buildPlaylistTracks = [];
|
let buildPlaylistTracks = [];
|
||||||
|
|
||||||
async function generateBuildPlaylist() {
|
async function generateBuildPlaylist() {
|
||||||
if (buildPlaylistSelectedArtists.length === 0) {
|
if (buildPlaylistSelectedArtists.length === 0) {
|
||||||
alert('Please select at least 1 artist');
|
showToast('Please select at least 1 artist', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -43827,9 +43848,8 @@ async function generateBuildPlaylist() {
|
||||||
const titleEl = document.getElementById('build-playlist-results-title');
|
const titleEl = document.getElementById('build-playlist-results-title');
|
||||||
const subtitleEl = document.getElementById('build-playlist-results-subtitle');
|
const subtitleEl = document.getElementById('build-playlist-results-subtitle');
|
||||||
|
|
||||||
// Show loading
|
// Show loading, hide search area
|
||||||
generateBtn.disabled = true;
|
generateBtn.disabled = true;
|
||||||
generateBtn.style.opacity = '0.5';
|
|
||||||
loadingIndicator.style.display = 'flex';
|
loadingIndicator.style.display = 'flex';
|
||||||
resultsWrapper.style.display = 'none';
|
resultsWrapper.style.display = 'none';
|
||||||
resultsContainer.innerHTML = '';
|
resultsContainer.innerHTML = '';
|
||||||
|
|
@ -43845,13 +43865,12 @@ async function generateBuildPlaylist() {
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to generate playlist');
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (!data.success || !data.playlist || !data.playlist.tracks) {
|
if (!response.ok || !data.success) {
|
||||||
throw new Error('Invalid playlist data');
|
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
|
// Store tracks globally
|
||||||
|
|
@ -43866,9 +43885,18 @@ async function generateBuildPlaylist() {
|
||||||
const metadata = data.playlist.metadata;
|
const metadata = data.playlist.metadata;
|
||||||
metadataDisplay.innerHTML = `
|
metadataDisplay.innerHTML = `
|
||||||
<div class="build-playlist-metadata">
|
<div class="build-playlist-metadata">
|
||||||
<p><strong>Total Tracks:</strong> ${metadata.total_tracks}</p>
|
<div class="bp-meta-stat">
|
||||||
<p><strong>Similar Artists Used:</strong> ${metadata.similar_artists_count}</p>
|
<span class="bp-meta-value">${metadata.total_tracks}</span>
|
||||||
<p><strong>Albums Sampled:</strong> ${metadata.albums_count}</p>
|
<span class="bp-meta-label">Tracks</span>
|
||||||
|
</div>
|
||||||
|
<div class="bp-meta-stat">
|
||||||
|
<span class="bp-meta-value">${metadata.similar_artists_count}</span>
|
||||||
|
<span class="bp-meta-label">Similar Artists</span>
|
||||||
|
</div>
|
||||||
|
<div class="bp-meta-stat">
|
||||||
|
<span class="bp-meta-value">${metadata.albums_count}</span>
|
||||||
|
<span class="bp-meta-label">Albums Sampled</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
@ -43880,11 +43908,11 @@ async function generateBuildPlaylist() {
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error generating playlist:', error);
|
console.error('Error generating playlist:', error);
|
||||||
resultsContainer.innerHTML = '<div class="error-message">Failed to generate playlist. Please try again.</div>';
|
resultsWrapper.style.display = 'none';
|
||||||
|
showToast(error.message || 'Failed to generate playlist', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
loadingIndicator.style.display = 'none';
|
loadingIndicator.style.display = 'none';
|
||||||
generateBtn.disabled = false;
|
generateBtn.disabled = false;
|
||||||
generateBtn.style.opacity = '1';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23956,65 +23956,150 @@ body {
|
||||||
BUILD A PLAYLIST STYLES
|
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 {
|
.build-playlist-container {
|
||||||
background: #1a1a1a;
|
background: #1a1a1a;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
margin-top: 20px;
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* -- Search Input -- */
|
||||||
.build-playlist-search-section {
|
.build-playlist-search-section {
|
||||||
position: relative;
|
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 {
|
#build-playlist-search {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 12px 16px;
|
padding: 12px 42px 12px 40px;
|
||||||
background: #2a2a2a;
|
background: #2a2a2a;
|
||||||
border: 1px solid #3a3a3a;
|
border: 1px solid #3a3a3a;
|
||||||
border-radius: 8px;
|
border-radius: 10px;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
outline: none;
|
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 {
|
#build-playlist-search:focus {
|
||||||
border-color: rgb(var(--accent-rgb));
|
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 {
|
.build-playlist-search-results {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 100%;
|
top: calc(100% + 4px);
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
background: #2a2a2a;
|
background: #222;
|
||||||
border: 1px solid #3a3a3a;
|
border: 1px solid #3a3a3a;
|
||||||
border-radius: 8px;
|
border-radius: 10px;
|
||||||
margin-top: 8px;
|
max-height: 320px;
|
||||||
max-height: 300px;
|
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
display: none;
|
display: none;
|
||||||
|
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.build-playlist-search-result {
|
.build-playlist-search-result {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 12px;
|
padding: 10px 14px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background-color 0.2s ease;
|
transition: background-color 0.15s ease;
|
||||||
border-bottom: 1px solid #3a3a3a;
|
border-bottom: 1px solid #2a2a2a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.build-playlist-search-result:last-child {
|
.build-playlist-search-result:last-child {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.build-playlist-search-result:hover {
|
.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 {
|
.build-playlist-search-result img {
|
||||||
|
|
@ -24022,35 +24107,72 @@ body {
|
||||||
height: 40px;
|
height: 40px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
.build-playlist-search-result span {
|
.bp-result-name {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 14px;
|
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 {
|
.build-playlist-no-results {
|
||||||
padding: 16px;
|
padding: 20px 16px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #999;
|
color: #777;
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* -- Selected Artists -- */
|
||||||
.build-playlist-selected-section {
|
.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;
|
color: #fff;
|
||||||
font-size: 16px;
|
font-size: 14px;
|
||||||
margin: 0 0 12px 0;
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.bp-selected-counter {
|
||||||
|
color: #666;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.build-playlist-selected-artists {
|
.build-playlist-selected-artists {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 12px;
|
gap: 10px;
|
||||||
min-height: 60px;
|
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 {
|
.build-playlist-selected-artist {
|
||||||
|
|
@ -24059,74 +24181,91 @@ body {
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
background: #2a2a2a;
|
background: #2a2a2a;
|
||||||
border: 1px solid #3a3a3a;
|
border: 1px solid #3a3a3a;
|
||||||
border-radius: 8px;
|
border-radius: 20px;
|
||||||
padding: 8px 12px;
|
padding: 6px 10px 6px 6px;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
.build-playlist-selected-artist:hover {
|
||||||
|
border-color: #555;
|
||||||
}
|
}
|
||||||
|
|
||||||
.build-playlist-selected-artist img {
|
.build-playlist-selected-artist img {
|
||||||
width: 32px;
|
width: 28px;
|
||||||
height: 32px;
|
height: 28px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
.build-playlist-selected-artist span {
|
.build-playlist-selected-artist span {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.build-playlist-remove-artist {
|
.build-playlist-remove-artist {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: #ff4444;
|
color: #777;
|
||||||
font-size: 20px;
|
font-size: 16px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin-left: 4px;
|
margin-left: 2px;
|
||||||
width: 20px;
|
width: 18px;
|
||||||
height: 20px;
|
height: 18px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: 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 {
|
.build-playlist-remove-artist:hover {
|
||||||
color: #ff6666;
|
color: #ff5555;
|
||||||
|
background: rgba(255,85,85,0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.build-playlist-no-selection {
|
.build-playlist-no-selection {
|
||||||
color: #999;
|
color: #555;
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
padding: 16px;
|
padding: 8px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* -- Generate Button & Loading -- */
|
||||||
.build-playlist-actions {
|
.build-playlist-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.build-playlist-generate-btn {
|
.build-playlist-generate-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
background: rgb(var(--accent-rgb));
|
background: rgb(var(--accent-rgb));
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 8px;
|
border-radius: 10px;
|
||||||
padding: 12px 24px;
|
padding: 12px 24px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.build-playlist-generate-btn:not(:disabled):hover {
|
.build-playlist-generate-btn:not(:disabled):hover {
|
||||||
background: rgb(var(--accent-light-rgb));
|
filter: brightness(1.15);
|
||||||
transform: scale(1.02);
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(var(--accent-rgb), 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.build-playlist-generate-btn:disabled {
|
.build-playlist-generate-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -24135,31 +24274,41 @@ body {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
.build-playlist-loading span {
|
||||||
.build-playlist-loading p {
|
|
||||||
color: #999;
|
color: #999;
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
margin: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* -- Metadata Stats -- */
|
||||||
.build-playlist-metadata {
|
.build-playlist-metadata {
|
||||||
background: #2a2a2a;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 16px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(3, 1fr);
|
||||||
gap: 16px;
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.build-playlist-metadata p {
|
.bp-meta-stat {
|
||||||
color: #fff;
|
background: #222;
|
||||||
font-size: 13px;
|
border-radius: 10px;
|
||||||
margin: 0;
|
padding: 14px 16px;
|
||||||
|
text-align: center;
|
||||||
|
border: 1px solid #2a2a2a;
|
||||||
}
|
}
|
||||||
|
.bp-meta-value {
|
||||||
.build-playlist-metadata strong {
|
display: block;
|
||||||
color: rgb(var(--accent-rgb));
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* =======================
|
/* =======================
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue