diff --git a/web_server.py b/web_server.py index 555edab9..8e697c5d 100644 --- a/web_server.py +++ b/web_server.py @@ -19207,6 +19207,20 @@ def get_version_info(): "title": "What's New in SoulSync", "subtitle": f"Version {SOULSYNC_VERSION} โ€” Latest Changes", "sections": [ + { + "title": "๐ŸŒณ Playlist Explorer โ€” Visual Discovery Tree", + "description": "Use playlists as seeds to discover full albums and discographies", + "features": [ + "โ€ข New Explorer page with interactive tree visualization", + "โ€ข Select any mirrored playlist and choose Albums or Discographies mode", + "โ€ข Tree builds progressively โ€” artist nodes appear as Spotify data streams in", + "โ€ข Click artists to expand and see all their albums with art, year, and track counts", + "โ€ข Select individual albums or entire branches, then add to wishlist in one click", + "โ€ข Albums mode shows only albums containing playlist tracks; Discographies shows everything", + "โ€ข SVG connecting lines with animated draw-in effect", + "โ€ข 'In Library' and 'In Playlist' badges on album cards" + ] + }, { "title": "๐Ÿ”ง Fix .LRC Files Written Without Timestamps", "description": "Plain lyrics now saved as .txt instead of invalid .lrc files", @@ -41781,6 +41795,364 @@ def get_mirrored_discovery_states(): logger.error(f"Error getting mirrored discovery states: {e}") return jsonify({"error": str(e)}), 500 +# ================================================================================================ +# PLAYLIST EXPLORER +# ================================================================================================ + +@app.route('/api/playlist-explorer/build-tree', methods=['POST']) +def playlist_explorer_build_tree(): + """Build a discovery tree from a mirrored playlist. + Streams NDJSON: one line per artist with their albums. + Works with Spotify, iTunes, or Deezer as the metadata source. + Uses and populates the metadata cache to avoid redundant API calls.""" + try: + data = request.get_json() + if not data: + return jsonify({"success": False, "error": "No data provided"}), 400 + + playlist_id = data.get('playlist_id') + mode = data.get('mode', 'albums') # 'albums' or 'discographies' + + if not playlist_id: + return jsonify({"success": False, "error": "playlist_id is required"}), 400 + if mode not in ('albums', 'discographies'): + return jsonify({"success": False, "error": "mode must be 'albums' or 'discographies'"}), 400 + + database = get_database() + playlist = database.get_mirrored_playlist(playlist_id) + if not playlist: + return jsonify({"success": False, "error": "Playlist not found"}), 404 + + tracks = database.get_mirrored_playlist_tracks(playlist_id) + if not tracks: + return jsonify({"success": False, "error": "Playlist has no tracks"}), 400 + + # Determine active metadata source + spotify_available = spotify_client and spotify_client.is_spotify_authenticated() + if spotify_available: + active_client = spotify_client + source_name = 'spotify' + else: + active_client = _get_metadata_fallback_client() + source_name = _get_metadata_fallback_source() + + cache = get_metadata_cache() + + # Parse extra_data and group tracks by artist using discovered data + artist_groups = {} + for t in tracks: + extra = {} + if t.get('extra_data'): + try: + extra = json.loads(t['extra_data']) if isinstance(t['extra_data'], str) else t['extra_data'] + except (json.JSONDecodeError, TypeError): + pass + + # Only use discovery data if it matches the active metadata source + is_discovered = extra.get('discovered', False) + provider = (extra.get('provider') or '').lower() + source_matches = provider == source_name or (provider in ('itunes', 'apple') and source_name == 'itunes') + + matched = extra.get('matched_data', {}) if (is_discovered and source_matches) else {} + artists_list = matched.get('artists', []) + primary_artist = artists_list[0] if artists_list else None + # Artists can be dicts {"name": "X", "id": "Y"} or plain strings "X" + if isinstance(primary_artist, dict): + artist_name = primary_artist.get('name') or (t.get('artist_name') or '').strip() + artist_id = primary_artist.get('id') or None + elif isinstance(primary_artist, str): + artist_name = primary_artist or (t.get('artist_name') or '').strip() + artist_id = None + else: + artist_name = (t.get('artist_name') or '').strip() + artist_id = None + + if not artist_name: + continue + + key = artist_name.lower() + if key not in artist_groups: + artist_groups[key] = { + 'name': artist_name, + 'artist_id': artist_id, # Pre-resolved from discovery + 'tracks': [], + 'album_names': set(), + 'discovered': extra.get('discovered', False), + } + # If we get an artist_id from a later track but didn't have one before, fill it in + if artist_id and not artist_groups[key].get('artist_id'): + artist_groups[key]['artist_id'] = artist_id + + artist_groups[key]['tracks'].append(t.get('track_name', '')) + # Get album name from discovered data or playlist field + album_name = '' + album_data = matched.get('album') + if isinstance(album_data, dict) and album_data.get('name'): + album_name = album_data['name'] + elif (t.get('album_name') or '').strip(): + album_name = t['album_name'].strip() + if album_name: + artist_groups[key]['album_names'].add(album_name) + + def _normalize_for_match(title): + import re + return re.sub(r'\s*[\(\[][^)\]]*[\)\]]', '', title).strip().lower() + + def _fetch_artist_discography(artist_name, known_artist_id=None): + """Fetch discography using the active client. Checks cache first, stores results after. + If known_artist_id is provided (from discovery cache), skips the name search.""" + # Check cache for this artist's discography + cache_key = f"explorer_disco_{artist_name.lower().strip()}" + cached = cache.get_entity(source_name, 'artist_discography', cache_key) if cache else None + if cached and isinstance(cached, dict) and cached.get('albums'): + logger.debug(f"Explorer: cache hit for '{artist_name}' discography") + return cached + + artist_id = known_artist_id + artist_image = None + + if artist_id: + # Already have the ID from discovery โ€” just fetch the artist image + try: + artist_info = active_client.get_artist(artist_id) + if artist_info: + if isinstance(artist_info, dict): + images = artist_info.get('images') or [] + artist_image = images[0].get('url') if images else None + elif hasattr(artist_info, 'image_url'): + artist_image = artist_info.image_url + except Exception: + pass + else: + # No pre-resolved ID โ€” search by name + try: + search_results = active_client.search_artists(artist_name, limit=5) + except Exception as e: + return {'success': False, 'error': f'Search failed: {e}'} + + if not search_results: + return {'success': False, 'error': f'"{artist_name}" not found'} + + # Find best match (exact first, then fuzzy) + best = None + for a in search_results: + if a.name.lower().strip() == artist_name.lower().strip(): + best = a + break + if not best: + best = search_results[0] + + artist_id = best.id + artist_image = best.image_url if hasattr(best, 'image_url') else None + + # Fetch albums + try: + all_albums = active_client.get_artist_albums(artist_id, album_type='album,single', limit=50) + except Exception as e: + return {'success': False, 'error': f'Album fetch failed: {e}'} + + if not all_albums: + return {'success': False, 'error': 'No albums found'} + + # Check which albums the user already owns + owned_titles = set() + try: + db = get_database() + with db._get_connection() as conn: + cursor = conn.cursor() + # Find all artists in DB matching this name + cursor.execute("SELECT id FROM artists WHERE LOWER(name) = LOWER(?)", (artist_name,)) + artist_rows = cursor.fetchall() + for ar in artist_rows: + cursor.execute("SELECT title FROM albums WHERE artist_id = ?", (ar['id'],)) + for alb_row in cursor.fetchall(): + owned_titles.add((alb_row['title'] or '').strip().lower()) + except Exception: + pass # Non-critical โ€” owned badges just won't show + + # Build release list + releases = [] + for album in all_albums: + # Skip albums where this artist isn't primary + if hasattr(album, 'artist_ids') and album.artist_ids and album.artist_ids[0] != artist_id: + continue + releases.append({ + 'title': album.name, + 'year': album.release_date[:4] if album.release_date else None, + 'image_url': album.image_url, + 'spotify_id': album.id, + 'track_count': album.total_tracks, + 'album_type': (album.album_type or 'album').lower(), + 'owned': (album.name or '').strip().lower() in owned_titles, + }) + + result = { + 'success': True, + 'name': artist_name, # Required for metadata cache validation + 'albums': releases, + 'artist_image': artist_image, + 'artist_id': artist_id, + 'artist_name': artist_name, + } + + # Store in cache + if cache and releases: + try: + cache.store_entity(source_name, 'artist_discography', cache_key, result) + except Exception: + pass + + return result + + def generate(): + yield json.dumps({ + "type": "meta", + "playlist_name": playlist.get('name', 'Unknown Playlist'), + "playlist_image": playlist.get('image_url', ''), + "total_artists": len(artist_groups), + "total_tracks": len(tracks), + "source": source_name, + }) + '\n' + + total_albums = 0 + + for idx, (key, group) in enumerate(artist_groups.items()): + artist_name = group['name'] + playlist_track_names = group['tracks'] + playlist_album_names = group['album_names'] + + try: + disco = _fetch_artist_discography(artist_name, group.get('artist_id')) + + if not disco.get('success'): + yield json.dumps({ + "type": "artist", + "name": artist_name, + "artist_id": None, + "image_url": None, + "playlist_tracks": playlist_track_names, + "albums": [], + "error": disco.get('error', 'Not found'), + }) + '\n' + time.sleep(0.1) + continue + + # Tag each release with in_playlist flag + # If no album names available, fall back to matching track names against single titles + match_names = playlist_album_names + if not match_names: + match_names = set(playlist_track_names) + + all_releases = [] + for release in disco.get('albums', []): + r = dict(release) + norm_title = _normalize_for_match(r['title']) + r['in_playlist'] = any( + _normalize_for_match(a) == norm_title or + norm_title in _normalize_for_match(a) or + _normalize_for_match(a) in norm_title + for a in match_names + ) + all_releases.append(r) + + # Filter based on mode + if mode == 'albums': + filtered = [r for r in all_releases if r['in_playlist']] + else: + filtered = all_releases + + filtered.sort(key=lambda r: (not r.get('in_playlist', False), -(int(r.get('year') or 0)))) + total_albums += len(filtered) + + yield json.dumps({ + "type": "artist", + "name": disco.get('artist_name', artist_name), + "artist_id": disco.get('artist_id'), + "image_url": disco.get('artist_image'), + "playlist_tracks": playlist_track_names, + "albums": filtered, + }) + '\n' + + except Exception as e: + logger.error(f"Explorer: error processing artist '{artist_name}': {e}") + yield json.dumps({ + "type": "artist", + "name": artist_name, + "artist_id": None, + "image_url": None, + "playlist_tracks": playlist_track_names, + "albums": [], + "error": str(e), + }) + '\n' + + # Rate limit protection between artists + if idx < len(artist_groups) - 1: + time.sleep(0.2) + + yield json.dumps({"type": "complete", "total_artists": len(artist_groups), "total_albums": total_albums}) + '\n' + + return Response(generate(), mimetype='application/x-ndjson', headers={ + 'Cache-Control': 'no-cache', + 'X-Accel-Buffering': 'no', + }) + + except Exception as e: + logger.error(f"Playlist Explorer build-tree error: {e}") + import traceback + traceback.print_exc() + return jsonify({"success": False, "error": str(e)}), 500 + + +@app.route('/api/playlist-explorer/album-tracks/', methods=['GET']) +def playlist_explorer_album_tracks(album_id): + """Fetch track listing for an album. Works with active metadata source. Caches results.""" + try: + # Determine source + spotify_available = spotify_client and spotify_client.is_spotify_authenticated() + source_name = 'spotify' if spotify_available else _get_metadata_fallback_source() + client = spotify_client if spotify_available else _get_metadata_fallback_client() + + if not client: + return jsonify({"success": False, "error": "No metadata source available"}), 400 + + # Check cache + cache = get_metadata_cache() + cache_key = f"album_tracks_{album_id}" + if cache: + cached = cache.get_entity(source_name, 'album_tracks', cache_key) + if cached and isinstance(cached, dict) and cached.get('tracks'): + return jsonify(cached) + + album_data = client.get_album(album_id) + if not album_data: + return jsonify({"success": False, "error": "Album not found"}), 404 + + tracks_raw = album_data.get('tracks', {}).get('items', []) + tracks = [] + for t in tracks_raw: + artists = ', '.join(a.get('name', '') for a in t.get('artists', [])) + tracks.append({ + 'name': t.get('name', 'Unknown'), + 'track_number': t.get('track_number', 0), + 'disc_number': t.get('disc_number', 1), + 'duration_ms': t.get('duration_ms', 0), + 'artists': artists, + }) + + result = {"success": True, "name": album_data.get('name', ''), "tracks": tracks} + + # Store in cache + if cache and tracks: + try: + cache.store_entity(source_name, 'album_tracks', cache_key, result) + except Exception: + pass + + return jsonify(result) + except Exception as e: + return jsonify({"success": False, "error": str(e)}), 500 + + def convert_beatport_results_to_spotify_tracks(discovery_results): """Convert Beatport discovery results to Spotify tracks format for sync""" spotify_tracks = [] diff --git a/webui/index.html b/webui/index.html index 7bcfa9b4..4e614f3c 100644 --- a/webui/index.html +++ b/webui/index.html @@ -186,6 +186,10 @@ Discover + + + + + + +
+ +
+ + + + + + +
+
+ + + + +
+
+ + +
+
+ + + + + + + + + + +
+

Select a playlist to explore

+

Choose a mirrored playlist and mode above, then click Explore to build the discovery tree

+
+
+
+ + + + + +
diff --git a/webui/static/helper.js b/webui/static/helper.js index 6beffab0..1e379635 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3403,6 +3403,7 @@ function closeHelperSearch() { const WHATS_NEW = { '2.1': [ // Newest features first + { title: 'Playlist Explorer', desc: 'New page: expand playlists into visual discovery trees of albums and discographies โ€” select and add to wishlist', page: 'playlist-explorer' }, { title: 'Fix Invalid .LRC Lyrics Files', desc: 'Plain lyrics now saved as .txt โ€” only synced (timestamped) lyrics get the .lrc extension' }, { title: 'Fix Collab Artist on Singles', desc: 'Single/playlist path templates now respect First Listed Artist setting โ€” $albumartist available for all template types' }, { title: 'Fix Enrichment Breaking Manual Matches', desc: 'Enriching a manually matched artist no longer reverts status to not_found โ€” uses stored ID for direct lookup' }, diff --git a/webui/static/script.js b/webui/static/script.js index 6edce13e..b51bd7c0 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -2830,6 +2830,9 @@ async function loadPageData(pageId) { } // Already initialized โ€” DOM content persists, no reload needed break; + case 'playlist-explorer': + initExplorer(); + break; case 'settings': initializeSettings(); await loadSettingsData(); @@ -63463,3 +63466,972 @@ window.updateEnhanceSelectedCount = updateEnhanceSelectedCount; window.submitEnhanceQuality = submitEnhanceQuality; // ===== END ENHANCE QUALITY MODAL ===== + +// ================================================================================== +// PLAYLIST EXPLORER โ€” Visual Discovery Tree +// ================================================================================== + +const _explorer = { + initialized: false, + mode: 'albums', + artists: [], + selectedAlbums: new Set(), + expandedArtists: new Set(), + building: false, + playlistId: null, + meta: null, + _resizeTimer: null, +}; + +function initExplorer() { + if (_explorer.initialized) return; + _explorer.initialized = true; + _explorer._playlists = []; + + fetch('/api/mirrored-playlists') + .then(r => r.json()) + .then(data => { + const playlists = Array.isArray(data) ? data : (data.playlists || []); + _explorer._playlists = playlists; + + if (playlists.length === 0) { + const scroll = document.getElementById('explorer-picker-scroll'); + if (scroll) scroll.innerHTML = '
No mirrored playlists found. Sync a playlist first.
'; + return; + } + + // Group by source + const groups = {}; + playlists.forEach(p => { + const src = (p.source || 'other').toLowerCase(); + if (!groups[src]) groups[src] = []; + groups[src].push(p); + }); + + // Render source tabs + const tabsEl = document.getElementById('explorer-picker-tabs'); + if (tabsEl) { + const sourceNames = { spotify: 'Spotify', tidal: 'Tidal', deezer: 'Deezer', youtube: 'YouTube', beatport: 'Beatport', file: 'File', other: 'Other' }; + const sources = Object.keys(groups); + if (sources.length <= 1) { + // Only one source โ€” no tabs needed + tabsEl.style.display = 'none'; + } else { + tabsEl.innerHTML = sources.map((src, i) => { + const label = sourceNames[src] || src.charAt(0).toUpperCase() + src.slice(1); + const count = groups[src].length; + return ``; + }).join(''); + } + + // Show first source + explorerRenderPickerCards(sources[0]); + } + }) + .catch(() => {}); +} + +function explorerSwitchPickerTab(source) { + document.querySelectorAll('.explorer-picker-tab').forEach(t => t.classList.toggle('active', t.dataset.source === source)); + explorerRenderPickerCards(source); +} + +function explorerRenderPickerCards(source) { + const scroll = document.getElementById('explorer-picker-scroll'); + if (!scroll) return; + + const filtered = _explorer._playlists.filter(p => (p.source || 'other').toLowerCase() === source); + scroll.innerHTML = filtered.map(p => { + const img = p.image_url || ''; + const total = p.total_count || p.track_count || 0; + const discovered = p.discovered_count || 0; + const pct = total > 0 ? Math.round((discovered / total) * 100) : 0; + const isReady = pct >= 50; + const isActive = _explorer.playlistId === p.id; + return ` +
+
+ ${img ? `` : '
'} +
+
+
${p.name || 'Untitled'}
+
${total} tracks · ${isReady ? `${pct}% discovered` : `${pct}% โ€” needs discovery`}
+
+ ${isReady ? '' : '
🔒
'} +
+ `; + }).join(''); +} + +function explorerSelectPlaylist(id, el) { + _explorer.playlistId = id; + document.querySelectorAll('.explorer-picker-card').forEach(c => c.classList.remove('active')); + if (el) el.classList.add('active'); +} + +function explorerRedirectToDiscover(playlistId) { + showToast('This playlist needs more tracks discovered before exploring. Redirecting to Sync...', 'info'); + navigateToPage('sync'); + // Switch to mirrored tab after page loads + setTimeout(() => { + const mirroredBtn = document.querySelector('.sync-tab-button[data-tab="mirrored"]'); + if (mirroredBtn) mirroredBtn.click(); + }, 200); +} + +function explorerSetMode(mode) { + _explorer.mode = mode; + document.querySelectorAll('.explorer-mode-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.mode === mode); + }); +} + +async function explorerBuildTree() { + const playlistId = _explorer.playlistId; + if (!playlistId) { + showToast('Select a playlist first', 'error'); + return; + } + if (_explorer.building) return; + + _explorer.building = true; + _explorer.artists = []; + _explorer.selectedAlbums.clear(); + _explorer.expandedArtists.clear(); + _explorer.playlistId = playlistId; + + const tree = document.getElementById('explorer-tree'); + const svg = document.getElementById('explorer-svg'); + const progress = document.getElementById('explorer-progress'); + const actionBar = document.getElementById('explorer-action-bar'); + const empty = document.getElementById('explorer-empty'); + const buildBtn = document.getElementById('explorer-build-btn'); + + if (empty) empty.style.display = 'none'; + if (actionBar) actionBar.style.display = 'none'; + if (progress) progress.style.display = 'flex'; + if (buildBtn) { buildBtn.disabled = true; buildBtn.textContent = 'Building...'; } + // Clear tree but preserve the SVG element (it lives inside the tree) + tree.innerHTML = ''; + _explorer._zoom = 1; + tree.style.transform = ''; + + try { + const response = await fetch('/api/playlist-explorer/build-tree', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ playlist_id: parseInt(playlistId), mode: _explorer.mode }) + }); + + if (!response.ok) { + const err = await response.json(); + throw new Error(err.error || 'Failed to build tree'); + } + + // Stream NDJSON + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let artistCount = 0; + let totalArtists = 0; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop(); + + for (const line of lines) { + if (!line.trim()) continue; + try { + const data = JSON.parse(line); + + if (data.type === 'meta') { + _explorer.meta = data; + totalArtists = data.total_artists; + _explorerRenderRoot(data); + } else if (data.type === 'artist') { + artistCount++; + _explorer.artists.push(data); + _explorerRenderArtistNode(data, artistCount); + + // Lines drawn after streaming completes (not during โ€” flex reflow drifts positions) + + // Update progress + const pct = Math.round((artistCount / totalArtists) * 100); + const fill = document.getElementById('explorer-progress-fill'); + const text = document.getElementById('explorer-progress-text'); + if (fill) fill.style.width = pct + '%'; + if (text) text.textContent = `Discovering artists... ${artistCount} of ${totalArtists}`; + } else if (data.type === 'complete') { + // Done + } + } catch (e) { + console.warn('Explorer: failed to parse NDJSON line', e); + } + } + } + + // Tree built โ€” show action bar, hide progress + if (actionBar) actionBar.style.display = 'flex'; + if (progress) progress.style.display = 'none'; + _explorerUpdateCount(); + + // Draw all connections now that the tree is stable + setTimeout(() => _explorerRedrawAllConnections(true), 100); + + } catch (err) { + showToast('Explorer: ' + err.message, 'error'); + if (empty) { empty.style.display = 'flex'; } + if (progress) progress.style.display = 'none'; + } finally { + _explorer.building = false; + if (buildBtn) { buildBtn.disabled = false; buildBtn.textContent = 'Explore'; } + } +} + +function _explorerRenderRoot(meta) { + const tree = document.getElementById('explorer-tree'); + const rootHtml = ` +
+
+
+ ${meta.playlist_image + ? `` + : '
' + } +
+
SOURCE
+
${meta.playlist_name}
+
${meta.total_tracks} tracks · ${meta.total_artists} artists
+
+
+
+
+ `; + tree.insertAdjacentHTML('afterbegin', rootHtml); + _explorer._artistRowSizes = []; // Track row capacities: [2, 3, 4, ...] + _explorer._artistCount = 0; + _explorer._currentRowIndex = 0; +} + +function _explorerGetOrCreateRow() { + const container = document.getElementById('explorer-artist-tiers'); + if (!container) return null; + + // Determine row sizes: 2, 3, 4, 5... (tree shape) + const rowCapacity = _explorer._currentRowIndex + 2; + const existingRows = container.querySelectorAll('.explorer-tier-artists'); + let currentRow = existingRows[existingRows.length - 1]; + + if (!currentRow || currentRow.children.length >= (_explorer._currentRowIndex + 2)) { + // Need a new row + _explorer._currentRowIndex = existingRows.length; + const newRow = document.createElement('div'); + newRow.className = 'explorer-tier explorer-tier-artists'; + container.appendChild(newRow); + return newRow; + } + return currentRow; +} + +function _explorerRenderArtistNode(artist, index) { + const row = _explorerGetOrCreateRow(); + if (!row) return; + + _explorer._artistCount++; + const albumCount = artist.albums ? artist.albums.length : 0; + const safeKey = (artist.name || '').replace(/[^a-zA-Z0-9]/g, '_'); + const hasError = !!artist.error; + + const html = ` +
+
+ ${artist.image_url + ? `` + : '' + } +
+
${artist.name || 'Unknown'}
+
${hasError ? 'Not found' : albumCount + ' album' + (albumCount !== 1 ? 's' : '')}
+
+ ${!hasError && albumCount > 0 ? '
' : ''} + ${hasError ? '
' : ''} +
+
+
+ `; + row.insertAdjacentHTML('beforeend', html); +} + +function explorerToggleArtist(key) { + const children = document.getElementById(`explorer-children-${key}`); + const node = document.getElementById(`explorer-node-${key}`); + if (!children || !node) return; + + const isExpanded = _explorer.expandedArtists.has(key); + if (isExpanded) { + _explorer.expandedArtists.delete(key); + children.innerHTML = ''; + node.classList.remove('expanded'); + } else { + _explorer.expandedArtists.add(key); + node.classList.add('expanded'); + + const artist = _explorer.artists.find(a => (a.name || '').replace(/[^a-zA-Z0-9]/g, '_') === key); + if (artist && artist.albums) { + const albumsHtml = artist.albums.map((album, i) => { + const id = album.spotify_id || `${key}_${i}`; + const selected = _explorer.selectedAlbums.has(id); + const owned = album.owned; + const inPlaylist = album.in_playlist; + + const typeLabel = album.album_type === 'single' ? 'Single' : album.album_type === 'ep' ? 'EP' : 'Album'; + return ` +
+
+ ${album.image_url + ? `` + : '' + } +
+
${album.title || 'Unknown'}
+
${album.year || ''} · ${album.track_count || '?'} tracks
+
+
+ +
+ ${owned ? '
Owned
' : ''} + ${inPlaylist ? '
โ™ซ
' : ''} +
+
+
+ `; + }).join(''); + children.innerHTML = albumsHtml; + } + } + + // Redraw SVG after DOM settles + requestAnimationFrame(() => setTimeout(() => _explorerRedrawAllConnections(), 50)); +} + +async function explorerExpandAlbumTracks(spotifyAlbumId, nodeKey) { + if (!spotifyAlbumId) return; + const tracksContainer = document.getElementById(`explorer-tracks-${nodeKey}`); + if (!tracksContainer) return; + + // Toggle: if already has content, collapse + if (tracksContainer.innerHTML) { + tracksContainer.innerHTML = ''; + requestAnimationFrame(() => setTimeout(() => _explorerRedrawAllConnections(), 50)); + return; + } + + try { + const response = await fetch(`/api/playlist-explorer/album-tracks/${spotifyAlbumId}`); + const data = await response.json(); + if (!data.success || !data.tracks) return; + + const tracksHtml = data.tracks.map((t, i) => ` +
+
+
+
${t.track_number}. ${t.name}
+
${_formatDuration(t.duration_ms)}
+
+
+
+ `).join(''); + tracksContainer.innerHTML = tracksHtml; + requestAnimationFrame(() => setTimeout(() => _explorerRedrawAllConnections(), 50)); + } catch (e) { + console.error('Failed to load album tracks:', e); + } +} + +function _formatDuration(ms) { + if (!ms) return ''; + const m = Math.floor(ms / 60000); + const s = Math.floor((ms % 60000) / 1000); + return `${m}:${s.toString().padStart(2, '0')}`; +} + +// Track double-click vs single-click on album nodes +let _explorerClickTimer = null; +let _explorerLastClickId = null; + +function explorerToggleAlbum(id) { + // Double-click detection: expand tracks + if (_explorerLastClickId === id && _explorerClickTimer) { + clearTimeout(_explorerClickTimer); + _explorerClickTimer = null; + _explorerLastClickId = null; + // Double-click โ€” expand tracks + const node = document.querySelector(`.explorer-node-album[data-id="${id}"]`); + const spotifyId = id.includes('_') ? '' : id; // Only real IDs, not fallback keys + explorerExpandAlbumTracks(spotifyId, id); + return; + } + + _explorerLastClickId = id; + _explorerClickTimer = setTimeout(() => { + _explorerClickTimer = null; + _explorerLastClickId = null; + + // Single click โ€” toggle selection + if (_explorer.selectedAlbums.has(id)) { + _explorer.selectedAlbums.delete(id); + } else { + _explorer.selectedAlbums.add(id); + } + + const node = document.querySelector(`.explorer-node-album[data-id="${id}"]`); + if (node) { + const isSelected = _explorer.selectedAlbums.has(id); + node.classList.toggle('selected', isSelected); + const check = node.querySelector('.explorer-node-select'); + if (check) check.classList.toggle('active', isSelected); + } + + _explorerUpdateCount(); + }, 250); + + _explorerUpdateCount(); +} + +function explorerSelectAll() { + _explorer.artists.forEach(a => { + (a.albums || []).forEach(album => { + if (album.spotify_id && !album.owned) _explorer.selectedAlbums.add(album.spotify_id); + }); + }); + _explorerRefreshAllCards(); + _explorerUpdateCount(); +} + +function explorerDeselectAll() { + _explorer.selectedAlbums.clear(); + _explorerRefreshAllCards(); + _explorerUpdateCount(); +} + +function _explorerRefreshAllCards() { + document.querySelectorAll('.explorer-node-album').forEach(node => { + const id = node.dataset.id; + const selected = _explorer.selectedAlbums.has(id); + node.classList.toggle('selected', selected); + const check = node.querySelector('.explorer-node-select'); + if (check) check.classList.toggle('active', selected); + }); +} + +function _explorerUpdateCount() { + const el = document.getElementById('explorer-selection-count'); + const count = _explorer.selectedAlbums.size; + if (el) el.textContent = `${count} album${count !== 1 ? 's' : ''} selected`; + _explorerRefreshArtistIndicators(); +} + +function _explorerRefreshArtistIndicators() { + // For each artist, check if any of their albums are selected โ€” add visual indicator + _explorer.artists.forEach(artist => { + const key = (artist.name || '').replace(/[^a-zA-Z0-9]/g, '_'); + const node = document.getElementById(`explorer-node-${key}`); + if (!node) return; + const hasSelected = (artist.albums || []).some(a => a.spotify_id && _explorer.selectedAlbums.has(a.spotify_id)); + node.classList.toggle('has-selection', hasSelected); + }); +} + +function explorerAddToWishlist() { + if (_explorer.selectedAlbums.size === 0) { + showToast('No albums selected', 'error'); + return; + } + + // Group selected albums by artist with full metadata + const artistSections = []; + for (const artist of _explorer.artists) { + const artistId = artist.artist_id || artist.spotify_id; + if (!artist.albums) continue; + const selected = artist.albums.filter(a => a.spotify_id && _explorer.selectedAlbums.has(a.spotify_id)); + if (selected.length === 0) continue; + artistSections.push({ artistId, name: artist.name, image: artist.image_url, albums: selected }); + } + + if (artistSections.length === 0) { showToast('No valid albums selected', 'error'); return; } + + // Build confirmation modal (mirrors discog-modal pattern) + const overlay = document.createElement('div'); + overlay.className = 'discog-modal-overlay'; + overlay.id = 'explorer-wishlist-overlay'; + + const totalAlbums = artistSections.reduce((s, a) => s + a.albums.length, 0); + const totalTracks = artistSections.reduce((s, a) => s + a.albums.reduce((t, al) => t + (al.track_count || 0), 0), 0); + + let cardsHtml = ''; + artistSections.forEach(section => { + cardsHtml += `
${_esc(section.name)}
`; + section.albums.forEach((album, i) => { + const year = album.year || ''; + const typeLabel = album.album_type === 'single' ? 'Single' : album.album_type === 'ep' ? 'EP' : 'Album'; + cardsHtml += ` + + `; + }); + }); + + overlay.innerHTML = ` +
+
+
+
+

Add to Wishlist

+

${artistSections.length} artist${artistSections.length !== 1 ? 's' : ''} ยท ${totalAlbums} releases

+
+ +
+
+
+ + + +
+
+ + +
+
+
${cardsHtml}
+ + +
+ `; + + document.body.appendChild(overlay); + requestAnimationFrame(() => overlay.classList.add('visible')); + _explorerWishlistUpdateCount(); + + document.getElementById('explorer-wishlist-submit')?.addEventListener('click', () => _explorerWishlistSubmit(artistSections)); +} + +function _explorerWishlistToggleFilter(btn) { + btn.classList.toggle('active'); + const type = btn.dataset.type; + // Scoped to explorer wishlist modal only + document.querySelectorAll(`#explorer-wishlist-overlay .discog-card[data-type="${type}"]`).forEach(card => { + card.style.display = btn.classList.contains('active') ? '' : 'none'; + }); + _explorerWishlistUpdateCount(); +} + +function _explorerWishlistUpdateCount() { + const checked = document.querySelectorAll('#explorer-wishlist-overlay .discog-card-cb:checked'); + let releases = 0, tracks = 0; + checked.forEach(cb => { + if (cb.closest('.discog-card').style.display !== 'none') { + releases++; + tracks += parseInt(cb.dataset.tracks) || 0; + } + }); + const info = document.getElementById('explorer-wishlist-info'); + const btn = document.getElementById('explorer-wishlist-submit-text'); + if (info) info.textContent = `${releases} release${releases !== 1 ? 's' : ''} ยท ${tracks} tracks`; + if (btn) btn.textContent = releases > 0 ? `Add ${releases} to Wishlist` : 'Select releases'; + const submitBtn = document.getElementById('explorer-wishlist-submit'); + if (submitBtn) submitBtn.disabled = releases === 0; +} + +async function _explorerWishlistSubmit(artistSections) { + const grid = document.getElementById('explorer-wishlist-grid'); + const progress = document.getElementById('explorer-wishlist-progress'); + const filterBar = document.querySelector('#explorer-wishlist-overlay .discog-filter-bar'); + const submitBtn = document.getElementById('explorer-wishlist-submit'); + + // Collect checked albums grouped by artist + const byArtist = {}; + document.querySelectorAll('#explorer-wishlist-overlay .discog-card-cb:checked').forEach(cb => { + if (cb.closest('.discog-card').style.display === 'none') return; + const card = cb.closest('.discog-card'); + const artistId = card.dataset.artistId; + const albumId = cb.dataset.albumId; + const title = card.querySelector('.discog-card-title')?.textContent || ''; + const img = card.querySelector('.discog-card-art img')?.src || ''; + if (!byArtist[artistId]) byArtist[artistId] = { albums: [], name: '' }; + byArtist[artistId].albums.push({ id: albumId, title, img, tracks: parseInt(cb.dataset.tracks) || 0 }); + }); + + // Fill in artist names + artistSections.forEach(s => { if (byArtist[s.artistId]) byArtist[s.artistId].name = s.name; }); + + // Switch to progress view + if (grid) grid.style.display = 'none'; + if (filterBar) filterBar.style.display = 'none'; + if (submitBtn) submitBtn.style.display = 'none'; + if (progress) { + progress.style.display = ''; + progress.innerHTML = ''; + for (const [artistId, data] of Object.entries(byArtist)) { + data.albums.forEach(album => { + const item = document.createElement('div'); + item.className = 'discog-progress-item active'; + item.id = `explorer-prog-${album.id}`; + item.innerHTML = ` +
${album.img ? `` : '♫'}
+
+
${_esc(album.title)}
+
Waiting...
+
+
+ `; + progress.appendChild(item); + }); + } + } + + const info = document.getElementById('explorer-wishlist-info'); + if (info) info.textContent = 'Processing...'; + + let totalAdded = 0; + + for (const [artistId, data] of Object.entries(byArtist)) { + // Sort by track count descending (deluxe editions first) BEFORE extracting IDs + data.albums.sort((a, b) => b.tracks - a.tracks); + const albumIds = data.albums.map(a => a.id); + + try { + const response = await fetch(`/api/artist/${artistId}/download-discography`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ album_ids: albumIds, artist_name: data.name }) + }); + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop(); + for (const line of lines) { + if (!line.trim()) continue; + try { + const result = JSON.parse(line); + if (result.status === 'complete') continue; // Summary line, skip + const item = document.getElementById(`explorer-prog-${result.album_id}`); + if (item) { + const statusEl = item.querySelector('.discog-prog-status'); + const iconEl = item.querySelector('.discog-prog-icon'); + if (result.status === 'done') { + const added = result.tracks_added || 0; + const skipped = result.tracks_skipped || 0; + totalAdded += added; + if (statusEl) statusEl.textContent = `Added ${added} track${added !== 1 ? 's' : ''}${skipped > 0 ? `, ${skipped} skipped` : ''}`; + if (iconEl) iconEl.innerHTML = ''; + item.classList.remove('active'); + item.classList.add('done'); + } else if (result.status === 'error') { + if (statusEl) statusEl.textContent = result.message || 'Error'; + if (iconEl) iconEl.innerHTML = ''; + item.classList.remove('active'); + item.classList.add('error'); + } + } + } catch (e) {} + } + } + } catch (e) { + console.error(`Explorer wishlist: failed for ${data.name}:`, e); + } + } + + if (info) info.textContent = `Done โ€” ${totalAdded} tracks added to wishlist`; + // Change cancel button label to "Close" + const cancelBtn = document.querySelector('#explorer-wishlist-overlay .discog-cancel-btn'); + if (cancelBtn) cancelBtn.textContent = 'Close'; + showToast(`Added ${totalAdded} tracks to wishlist`, 'success'); + + // Mark albums as added on the tree + _explorer.selectedAlbums.forEach(id => { + const node = document.querySelector(`.explorer-node-album[data-id="${id}"]`); + if (node) { node.classList.add('added'); node.classList.remove('selected'); } + }); + _explorer.selectedAlbums.clear(); + _explorerUpdateCount(); + _explorerRefreshArtistIndicators(); +} + +function _explorerEnsureDefs() { + const svg = document.getElementById('explorer-svg'); + if (!svg || svg.querySelector('defs')) return; + // Read accent color from CSS custom property + const accentRgb = getComputedStyle(document.documentElement).getPropertyValue('--accent-rgb').trim() || '100,200,255'; + const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs'); + defs.innerHTML = ` + + + + + + + + + `; + svg.appendChild(defs); +} + +function _explorerDrawConnectionToArtist(artistIndex) { + // Incremental: draw ONLY this artist's connection. Don't clear existing. + // Flex reflow within the current row may shift siblings, but the visual drift + // is minor and gets corrected by the final redraw after streaming completes. + const svg = document.getElementById('explorer-svg'); + const root = document.getElementById('explorer-root'); + if (!svg || !root) return; + + _explorerEnsureDefs(); + _explorerSizeSvg(); + + const artistNodes = document.querySelectorAll('.explorer-node-artist'); + const artistNode = artistNodes[artistIndex]; + if (!artistNode) return; + + const rc = _explorerGetPos(root); + const ac = _explorerGetPos(artistNode); + _explorerDrawCurve(svg, rc.cx, rc.bottom, ac.cx, ac.top, 'root', true); +} + +function _explorerRedrawAllConnections(animate = false) { + const svg = document.getElementById('explorer-svg'); + const root = document.getElementById('explorer-root'); + if (!svg || !root) return; + + _explorerEnsureDefs(); + _explorerSizeSvg(); + + // Clear existing lines but keep defs + svg.querySelectorAll('path').forEach(p => p.remove()); + + const rc = _explorerGetPos(root); + + document.querySelectorAll('.explorer-node-artist').forEach(artistNode => { + const ac = _explorerGetPos(artistNode); + _explorerDrawCurve(svg, rc.cx, rc.bottom, ac.cx, ac.top, 'root', animate); + + if (artistNode.classList.contains('expanded')) { + const branch = artistNode.closest('.explorer-branch'); + if (!branch) return; + branch.querySelectorAll(':scope > .explorer-children > .explorer-branch > .explorer-node-album').forEach(albumNode => { + const alc = _explorerGetPos(albumNode); + _explorerDrawCurve(svg, ac.cx, ac.bottom, alc.cx, alc.top, 'album', animate); + + const albumBranch = albumNode.closest('.explorer-branch'); + if (albumBranch) { + albumBranch.querySelectorAll(':scope > .explorer-children > .explorer-branch > .explorer-node-track').forEach(trackNode => { + const tc = _explorerGetPos(trackNode); + _explorerDrawCurve(svg, alc.cx, alc.bottom, tc.cx, tc.top, 'track', animate); + }); + } + }); + } + }); +} + +function _explorerSizeSvg() { + const svg = document.getElementById('explorer-svg'); + const tree = document.getElementById('explorer-tree'); + if (!svg || !tree) return; + // SVG is inside the tree. Use scrollWidth/scrollHeight which are unscaled. + // Add padding to ensure lines near edges aren't clipped. + const w = Math.max(tree.scrollWidth, tree.offsetWidth) + 40; + const h = Math.max(tree.scrollHeight, tree.offsetHeight) + 40; + svg.setAttribute('width', w); + svg.setAttribute('height', h); + svg.setAttribute('viewBox', `0 0 ${w} ${h}`); +} + +function _explorerGetPos(el) { + // SVG is inside the tree โ€” positions are relative to tree, unscaled + const tree = document.getElementById('explorer-tree'); + if (!tree) return { cx: 0, top: 0, bottom: 0 }; + const tRect = tree.getBoundingClientRect(); + const r = el.getBoundingClientRect(); + const scale = _explorer._zoom || 1; + // getBoundingClientRect returns scaled coords; divide by scale to get unscaled tree-space coords + return { + cx: (r.left + r.width / 2 - tRect.left) / scale, + top: (r.top - tRect.top) / scale, + bottom: (r.bottom - tRect.top) / scale, + }; +} + +function _explorerDrawCurve(svg, x1, y1, x2, y2, type, animate) { + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + const midY = y1 + (y2 - y1) * 0.45; + path.setAttribute('d', `M ${x1} ${y1} C ${x1} ${midY}, ${x2} ${midY}, ${x2} ${y2}`); + + if (type === 'root') { + path.setAttribute('stroke', 'url(#explorer-grad-root)'); + path.setAttribute('stroke-width', '1.5'); + } else if (type === 'album') { + path.setAttribute('stroke', 'url(#explorer-grad-album)'); + path.setAttribute('stroke-width', '1'); + } else { + path.setAttribute('stroke', 'rgba(255,255,255,0.05)'); + path.setAttribute('stroke-width', '0.8'); + } + path.setAttribute('fill', 'none'); + + svg.appendChild(path); + + if (animate) { + const len = path.getTotalLength(); + path.setAttribute('class', 'explorer-line explorer-line-animated'); + path.style.strokeDasharray = len; + path.style.strokeDashoffset = len; + } else { + path.setAttribute('class', 'explorer-line'); + } +} + +// โ”€โ”€ Zoom & Pan โ”€โ”€ +_explorer._zoom = 1; +_explorer._panX = 0; +_explorer._panY = 0; +_explorer._isPanning = false; +_explorer._panStartX = 0; +_explorer._panStartY = 0; +_explorer._panStartScrollX = 0; +_explorer._panStartScrollY = 0; + +function _explorerApplyTransform() { + const tree = document.getElementById('explorer-tree'); + if (tree) { + tree.style.transform = `scale(${_explorer._zoom})`; + tree.style.transformOrigin = 'top center'; + } + _explorerSizeSvg(); + requestAnimationFrame(() => _explorerRedrawAllConnections()); +} + +function explorerZoom(delta) { + _explorer._zoom = Math.max(0.2, Math.min(3, _explorer._zoom + delta)); + _explorerApplyTransform(); +} + +function explorerFitToView() { + const viewport = document.getElementById('explorer-viewport'); + const tree = document.getElementById('explorer-tree'); + if (!viewport || !tree) return; + + // Reset zoom to measure natural size + _explorer._zoom = 1; + tree.style.transform = 'scale(1)'; + + requestAnimationFrame(() => { + const treeW = tree.scrollWidth; + const treeH = tree.scrollHeight; + const vpW = viewport.clientWidth - 40; + const vpH = viewport.clientHeight - 40; + + if (treeW > 0 && treeH > 0) { + _explorer._zoom = Math.min(vpW / treeW, vpH / treeH, 1.5); + _explorer._zoom = Math.max(0.2, Math.min(3, _explorer._zoom)); + } + + _explorerApplyTransform(); + viewport.scrollTop = 0; + viewport.scrollLeft = Math.max(0, (tree.scrollWidth * _explorer._zoom - vpW) / 2); + }); +} + +// Scroll wheel zoom (no modifier needed inside viewport) +document.addEventListener('wheel', (e) => { + const viewport = document.getElementById('explorer-viewport'); + if (!viewport || !viewport.contains(e.target)) return; + // Check if we're on the explorer page + const page = document.getElementById('playlist-explorer-page'); + if (!page || !page.classList.contains('active')) return; + + e.preventDefault(); + const step = e.deltaY > 0 ? -0.08 : 0.08; + explorerZoom(step); +}, { passive: false }); + +// Middle-click / right-click drag to pan +document.addEventListener('mousedown', (e) => { + const viewport = document.getElementById('explorer-viewport'); + if (!viewport || !viewport.contains(e.target)) return; + // Middle click (button 1) or right click (button 2) + if (e.button !== 1 && e.button !== 2) return; + + e.preventDefault(); + _explorer._isPanning = true; + _explorer._panStartX = e.clientX; + _explorer._panStartY = e.clientY; + _explorer._panStartScrollX = viewport.scrollLeft; + _explorer._panStartScrollY = viewport.scrollTop; + viewport.style.cursor = 'grabbing'; +}); + +document.addEventListener('mousemove', (e) => { + if (!_explorer._isPanning) return; + const viewport = document.getElementById('explorer-viewport'); + if (!viewport) return; + const dx = e.clientX - _explorer._panStartX; + const dy = e.clientY - _explorer._panStartY; + viewport.scrollLeft = _explorer._panStartScrollX - dx; + viewport.scrollTop = _explorer._panStartScrollY - dy; +}); + +document.addEventListener('mouseup', (e) => { + if (!_explorer._isPanning) return; + _explorer._isPanning = false; + const viewport = document.getElementById('explorer-viewport'); + if (viewport) viewport.style.cursor = ''; +}); + +// Suppress context menu on right-click inside viewport (for panning) +document.addEventListener('contextmenu', (e) => { + const viewport = document.getElementById('explorer-viewport'); + if (viewport && viewport.contains(e.target)) { + e.preventDefault(); + } +}); + +// Debounced redraw on resize +window.addEventListener('resize', () => { + if (_explorer.artists.length === 0) return; + clearTimeout(_explorer._resizeTimer); + _explorer._resizeTimer = setTimeout(() => _explorerRedrawAllConnections(), 150); +}); diff --git a/webui/static/style.css b/webui/static/style.css index daee60eb..93a42344 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -41199,6 +41199,21 @@ textarea.enhanced-meta-field-input { .discog-select-btn:hover { color: rgba(255,255,255,0.7); border-color: rgba(255,255,255,0.15); } /* Album grid */ +.discog-section-header { + grid-column: 1 / -1; + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.8px; + color: rgba(255, 255, 255, 0.35); + padding: 10px 4px 4px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +.discog-section-header:first-child { + padding-top: 0; +} + .discog-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 8px; padding: 14px 20px; overflow-y: auto; flex: 1; min-height: 0; @@ -49007,3 +49022,1481 @@ tr.tag-diff-same { /* Section titles */ #settings-page .settings-group > h3 { padding: 24px 0 10px; } } + + +/* ================================================================================== + PLAYLIST EXPLORER โ€” Visual Discovery Tree + ================================================================================== */ + +.explorer-container { + padding: 24px 32px; + display: flex; + flex-direction: column; + min-height: 100%; +} + +/* Playlist card picker */ +.explorer-playlist-picker { + margin-bottom: 16px; +} + +.explorer-picker-top { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + margin-bottom: 10px; + flex-wrap: wrap; +} + +.explorer-picker-tabs { + display: flex; + gap: 4px; + margin-bottom: 10px; +} + +.explorer-picker-tab { + padding: 6px 14px; + font-size: 12px; + font-weight: 600; + color: rgba(255, 255, 255, 0.4); + background: none; + border: 1px solid transparent; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s; + display: flex; + align-items: center; + gap: 6px; +} + +.explorer-picker-tab:hover { + color: rgba(255, 255, 255, 0.65); + background: rgba(255, 255, 255, 0.04); +} + +.explorer-picker-tab.active { + color: #fff; + background: rgba(var(--accent-rgb), 0.15); + border-color: rgba(var(--accent-rgb), 0.25); +} + +.explorer-picker-tab-count { + font-size: 10px; + font-weight: 700; + color: rgba(255, 255, 255, 0.3); + background: rgba(255, 255, 255, 0.06); + padding: 1px 6px; + border-radius: 10px; + min-width: 18px; + text-align: center; +} + +.explorer-picker-tab.active .explorer-picker-tab-count { + color: rgba(var(--accent-rgb), 0.9); + background: rgba(var(--accent-rgb), 0.15); +} + +.explorer-picker-scroll { + display: flex; + gap: 10px; + overflow-x: auto; + padding: 4px 2px 8px; + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, 0.1) transparent; +} + +.explorer-picker-scroll::-webkit-scrollbar { + height: 4px; +} + +.explorer-picker-scroll::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.1); + border-radius: 4px; +} + +.explorer-picker-card { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 14px 8px 8px; + min-width: 200px; + max-width: 260px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 12px; + cursor: pointer; + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); + flex-shrink: 0; +} + +.explorer-picker-card:hover { + background: rgba(255, 255, 255, 0.06); + border-color: rgba(255, 255, 255, 0.12); + transform: translateY(-2px) scale(1.02); + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.25); +} + +.explorer-picker-card.active { + border-color: rgba(var(--accent-rgb), 0.5); + background: rgba(var(--accent-rgb), 0.08); + box-shadow: 0 0 0 1px rgba(var(--accent-rgb), 0.2), 0 4px 16px rgba(var(--accent-rgb), 0.1); +} + +.explorer-picker-card-art { + width: 44px; + height: 44px; + border-radius: 8px; + overflow: hidden; + flex-shrink: 0; + background: rgba(255, 255, 255, 0.04); +} + +.explorer-picker-card-art img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.explorer-picker-card-art-placeholder { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + color: rgba(255, 255, 255, 0.15); + font-size: 18px; +} + +.explorer-picker-card-info { + min-width: 0; + flex: 1; +} + +.explorer-picker-card-name { + font-size: 12px; + font-weight: 600; + color: rgba(255, 255, 255, 0.85); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.explorer-picker-card-meta { + font-size: 10px; + color: rgba(255, 255, 255, 0.35); + margin-top: 2px; +} + +.explorer-picker-card.not-ready { + opacity: 0.5; + border-style: dashed; +} + +.explorer-picker-card.not-ready:hover { + opacity: 0.7; + border-color: rgba(245, 166, 35, 0.3); +} + +.explorer-picker-not-ready { + color: rgba(245, 166, 35, 0.8); +} + +.explorer-picker-card-lock { + position: absolute; + top: 6px; + right: 8px; + font-size: 12px; + opacity: 0.5; +} + +.explorer-picker-card { + position: relative; +} + +.explorer-picker-empty { + font-size: 13px; + color: rgba(255, 255, 255, 0.3); + padding: 16px 0; +} + +.explorer-controls { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +/* Mode toggle โ€” segmented control */ +.explorer-mode-toggle { + display: flex; + background: rgba(255, 255, 255, 0.03); + border-radius: 10px; + overflow: hidden; + position: relative; +} + +.explorer-mode-btn { + padding: 10px 18px; + font-size: 12px; + font-weight: 600; + color: rgba(255, 255, 255, 0.4); + background: none; + border: none; + cursor: pointer; + transition: all 0.25s; + position: relative; + z-index: 1; + letter-spacing: 0.2px; +} + +.explorer-mode-btn.active { + color: #fff; + background: rgba(var(--accent-rgb), 0.25); + border-radius: 8px; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2); +} + +.explorer-mode-btn:hover:not(.active) { + color: rgba(255, 255, 255, 0.65); +} + +/* Explore button โ€” primary CTA */ +.explorer-build-btn { + padding: 10px 24px; + font-size: 13px; + font-weight: 700; + color: #fff; + background: var(--accent); + border: none; + border-radius: 10px; + cursor: pointer; + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); + letter-spacing: 0.3px; + position: relative; + overflow: hidden; +} + +.explorer-build-btn::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(135deg, rgba(255, 255, 255, 0.15) 0%, transparent 50%); + border-radius: inherit; +} + +.explorer-build-btn:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: 0 6px 24px rgba(var(--accent-rgb), 0.35); + filter: brightness(1.1); +} + +.explorer-build-btn:active:not(:disabled) { + transform: translateY(0); + filter: brightness(0.95); +} + +.explorer-build-btn:disabled { + opacity: 0.5; + cursor: default; + filter: saturate(0.5); +} + +/* Action bar โ€” sticky at top */ +.explorer-action-bar { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 8px; + padding: 12px 20px; + background: rgba(18, 18, 22, 0.9); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 12px; + margin-bottom: 16px; + backdrop-filter: blur(12px); + position: sticky; + top: 0; + z-index: 20; + animation: explorer-action-bar-in 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) both; +} + +@keyframes explorer-action-bar-in { + from { opacity: 0; transform: translateY(-12px); } + to { opacity: 1; transform: none; } +} + +.explorer-nav-hint { + font-size: 10px; + color: rgba(255, 255, 255, 0.2); + width: 100%; + text-align: center; + margin-top: 2px; +} + +.explorer-selection-count { + font-size: 13px; + color: rgba(255, 255, 255, 0.6); + font-weight: 500; +} + +.explorer-action-buttons { + display: flex; + gap: 8px; +} + +.explorer-action-btn { + padding: 7px 16px; + font-size: 11px; + font-weight: 600; + color: rgba(255, 255, 255, 0.55); + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.07); + border-radius: 8px; + cursor: pointer; + transition: all 0.2s; + letter-spacing: 0.2px; +} + +.explorer-action-btn:hover { + background: rgba(255, 255, 255, 0.1); + color: #fff; + border-color: rgba(255, 255, 255, 0.15); +} + +.explorer-action-btn.primary { + background: var(--accent); + border-color: transparent; + color: #fff; + font-weight: 700; + padding: 8px 22px; + font-size: 12px; + position: relative; + overflow: hidden; +} + +.explorer-action-btn.primary::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(135deg, rgba(255, 255, 255, 0.15) 0%, transparent 50%); + border-radius: inherit; +} + +.explorer-action-btn.primary:hover { + box-shadow: 0 4px 18px rgba(var(--accent-rgb), 0.4); + filter: brightness(1.1); + transform: translateY(-1px); +} + +/* Viewport */ +.explorer-viewport { + position: relative; + flex: 1; + overflow: auto; + min-height: 400px; + cursor: default; + /* Enable smooth scrolling for pan operations */ +} + +.explorer-svg { + position: absolute; + top: 0; + left: 0; + pointer-events: none; + z-index: 0; + overflow: visible; +} + +.explorer-tree { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + padding-bottom: 60px; + transform-origin: top center; + /* No transition โ€” zoom must be instant so SVG coordinates are correct */ +} + +/* Empty state */ +.explorer-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 80px 20px; + text-align: center; +} + +.explorer-empty-icon { + width: 80px; + height: 80px; + color: rgba(255, 255, 255, 0.12); + margin-bottom: 20px; +} + +.explorer-empty-icon svg { + width: 100%; + height: 100%; +} + +.explorer-empty-icon svg circle, +.explorer-empty-icon svg line { + stroke-dasharray: 200; + stroke-dashoffset: 200; + animation: explorer-empty-draw 2s ease forwards; +} + +.explorer-empty-icon svg circle:nth-child(1) { animation-delay: 0s; } +.explorer-empty-icon svg line:nth-child(2) { animation-delay: 0.2s; } +.explorer-empty-icon svg line:nth-child(3) { animation-delay: 0.4s; } +.explorer-empty-icon svg line:nth-child(4) { animation-delay: 0.4s; } +.explorer-empty-icon svg circle:nth-child(5) { animation-delay: 0.7s; } +.explorer-empty-icon svg circle:nth-child(6) { animation-delay: 0.7s; } +.explorer-empty-icon svg circle:nth-child(7) { animation-delay: 0.7s; } +.explorer-empty-icon svg line:nth-child(8) { animation-delay: 0.5s; } + +@keyframes explorer-empty-draw { + to { stroke-dashoffset: 0; } +} + +.explorer-empty-title { + font-size: 18px; + font-weight: 600; + color: rgba(255, 255, 255, 0.4); + margin: 0 0 8px; +} + +.explorer-empty-desc { + font-size: 13px; + color: rgba(255, 255, 255, 0.25); + margin: 0; + max-width: 400px; +} + +/* Progress bar */ +.explorer-progress { + display: flex; + align-items: center; + gap: 14px; + padding: 10px 0; +} + +.explorer-progress-bar { + flex: 1; + height: 4px; + background: rgba(255, 255, 255, 0.06); + border-radius: 4px; + overflow: hidden; +} + +.explorer-progress-fill { + height: 100%; + width: 0; + background: linear-gradient(90deg, rgba(var(--accent-rgb), 0.6), var(--accent)); + border-radius: 4px; + transition: width 0.4s ease; + position: relative; + overflow: hidden; +} + +.explorer-progress-fill::after { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.25), transparent); + animation: explorer-shimmer 1.5s ease-in-out infinite; +} + +@keyframes explorer-shimmer { + to { left: 100%; } +} + +.explorer-progress-text { + font-size: 12px; + color: rgba(255, 255, 255, 0.4); + white-space: nowrap; +} + +/* โ”€โ”€ Node Graph Tree โ”€โ”€ */ + +/* Zoom controls */ +.explorer-zoom-controls { + position: sticky; + top: 12px; + float: right; + display: flex; + flex-direction: column; + gap: 4px; + z-index: 50; + margin-right: 8px; +} + +.explorer-zoom-btn { + width: 32px; + height: 32px; + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(18, 18, 22, 0.85); + backdrop-filter: blur(8px); + color: rgba(255, 255, 255, 0.7); + font-size: 16px; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; +} + +.explorer-zoom-btn:hover { + background: rgba(var(--accent-rgb), 0.2); + border-color: rgba(var(--accent-rgb), 0.3); + color: #fff; +} + +/* Tiers: horizontal rows of nodes โ€” z-index: 1 so they paint above the SVG (z-index: 0) */ +.explorer-tier { + display: flex; + justify-content: center; + flex-wrap: nowrap; + gap: 20px; + padding: 10px 0; + position: relative; + z-index: 1; +} + +.explorer-tier-root { + margin-bottom: 8px; +} + +.explorer-tier-artists { + gap: 24px; + margin-bottom: 4px; +} + +.explorer-artist-tiers { + display: flex; + flex-direction: column; + align-items: center; + position: relative; + z-index: 1; +} + +/* Branch: wraps a node + its children below */ +.explorer-branch { + display: flex; + flex-direction: column; + align-items: center; + position: relative; + z-index: 1; + animation: explorer-node-enter 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) both; + animation-delay: var(--enter-delay, 0s); +} + +/* Children container (albums under artist, tracks under album) */ +.explorer-children { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 14px; + padding-top: 20px; + position: relative; + z-index: 1; +} + +.explorer-children:empty { + display: none; +} + +/* โ”€โ”€ Base Node โ”€โ”€ */ +.explorer-node { + position: relative; + border-radius: 50%; + overflow: hidden; + cursor: pointer; + transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4); + border: 2px solid rgba(255, 255, 255, 0.08); +} + +.explorer-node:hover { + transform: scale(1.08); + box-shadow: 0 8px 40px rgba(0, 0, 0, 0.5), 0 0 20px rgba(var(--accent-rgb), 0.15); + border-color: rgba(var(--accent-rgb), 0.3); + z-index: 10; +} + +/* Ripple ring on artist hover */ +.explorer-node-artist::after { + content: ''; + position: absolute; + inset: -6px; + border-radius: 50%; + border: 1.5px solid rgba(var(--accent-rgb), 0); + transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); + pointer-events: none; +} + +.explorer-node-artist:hover::after { + inset: -10px; + border-color: rgba(var(--accent-rgb), 0.2); +} + +.explorer-node.expanded { + border-color: rgba(var(--accent-rgb), 0.6); + box-shadow: 0 0 0 4px rgba(var(--accent-rgb), 0.15), 0 8px 32px rgba(0, 0, 0, 0.4); +} + +.explorer-node-img { + width: 100%; + height: 100%; + object-fit: cover; + position: absolute; + top: 0; + left: 0; +} + +.explorer-node-img-placeholder { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background: rgba(var(--accent-rgb), 0.1); + color: rgba(255, 255, 255, 0.2); + font-size: 32px; + position: absolute; + top: 0; + left: 0; +} + +/* Label overlay at bottom of node */ +.explorer-node-label { + position: absolute; + bottom: 0; + left: 0; + right: 0; + padding: 10px 10px 8px; + background: linear-gradient(to top, rgba(0, 0, 0, 0.85) 0%, rgba(0, 0, 0, 0.5) 60%, transparent 100%); + text-align: center; + z-index: 2; +} + +.explorer-node-label-sub { + font-size: 8px; + font-weight: 800; + letter-spacing: 2px; + color: rgba(var(--accent-rgb), 0.9); + text-transform: uppercase; + margin-bottom: 2px; +} + +.explorer-node-label-main { + font-size: 12px; + font-weight: 700; + color: #fff; + line-height: 1.2; + text-shadow: 0 1px 4px rgba(0, 0, 0, 0.6); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.explorer-node-label-meta { + font-size: 9px; + color: rgba(255, 255, 255, 0.5); + margin-top: 2px; +} + +/* โ”€โ”€ Root Node โ”€โ”€ */ +.explorer-node-root { + width: 160px; + height: 160px; + border-radius: 24px; + border-width: 2px; + border-color: rgba(var(--accent-rgb), 0.3); +} + +.explorer-node-root .explorer-node-label { + padding: 14px 12px 12px; + border-radius: 0 0 22px 22px; +} + +.explorer-node-root .explorer-node-label-main { + font-size: 14px; +} + +.explorer-node-glow { + position: absolute; + inset: -20px; + background: radial-gradient(circle, rgba(var(--accent-rgb), 0.2) 0%, transparent 70%); + z-index: 0; + animation: explorer-glow-pulse 4s ease-in-out infinite; +} + +@keyframes explorer-glow-pulse { + 0%, 100% { opacity: 0.5; transform: scale(1); } + 50% { opacity: 1; transform: scale(1.05); } +} + +/* โ”€โ”€ Artist Node โ”€โ”€ */ +.explorer-node-artist { + width: 82px; + height: 82px; +} + +.explorer-node-artist .explorer-node-label-main { + font-size: 11px; +} + +/* Artist has selected albums indicator */ +.explorer-node-artist.has-selection { + border-color: rgba(var(--accent-rgb), 0.5); + box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.15), 0 4px 16px rgba(var(--accent-rgb), 0.1); +} + +.explorer-node-artist.has-selection::after { + inset: -10px; + border-color: rgba(var(--accent-rgb), 0.25); + animation: explorer-selected-breathe 2.5s ease-in-out infinite; +} + +.explorer-node-artist.error { + border-color: rgba(255, 71, 87, 0.3); + opacity: 0.5; + cursor: default; +} + +/* Expand hint arrow on artist nodes */ +.explorer-node-expand-hint { + position: absolute; + bottom: 2px; + left: 50%; + transform: translateX(-50%); + font-size: 10px; + color: rgba(255, 255, 255, 0.3); + z-index: 5; + transition: all 0.2s; + line-height: 1; +} + +.explorer-node-artist:hover .explorer-node-expand-hint { + color: var(--accent); + bottom: 0px; +} + +.explorer-node-artist.expanded .explorer-node-expand-hint { + transform: translateX(-50%) rotate(180deg); + color: var(--accent); +} + +.explorer-node-error-ring { + position: absolute; + inset: -4px; + border-radius: 50%; + border: 2px dashed rgba(255, 71, 87, 0.3); + z-index: 3; +} + +/* โ”€โ”€ Album Node โ”€โ”€ */ +.explorer-node-album { + width: 90px; + height: 90px; + border-radius: 16px; +} + +.explorer-node-album .explorer-node-label { + padding: 6px 6px 6px; + border-radius: 0 0 14px 14px; +} + +.explorer-node-album .explorer-node-label-main { + font-size: 10px; +} + +.explorer-node-album .explorer-node-label-meta { + font-size: 8px; +} + +.explorer-node-album.selected { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.25), 0 4px 20px rgba(var(--accent-rgb), 0.2); + animation: explorer-selected-breathe 2.5s ease-in-out infinite; +} + +@keyframes explorer-selected-breathe { + 0%, 100% { box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.2), 0 4px 16px rgba(var(--accent-rgb), 0.1); } + 50% { box-shadow: 0 0 0 4px rgba(var(--accent-rgb), 0.35), 0 4px 24px rgba(var(--accent-rgb), 0.2); } +} + +.explorer-node-album.owned { + opacity: 0.45; +} + +.explorer-node-album.added { + border-color: rgba(76, 175, 80, 0.6); + box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.2); +} + +/* Selection checkmark */ +.explorer-node-select { + position: absolute; + top: 6px; + right: 6px; + width: 22px; + height: 22px; + border-radius: 50%; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); + border: 2px solid rgba(255, 255, 255, 0.3); + display: flex; + align-items: center; + justify-content: center; + z-index: 5; + opacity: 0; + transition: all 0.2s; +} + +.explorer-node-album:hover .explorer-node-select, +.explorer-node-select.active { + opacity: 1; +} + +.explorer-node-select.active { + background: var(--accent); + border-color: var(--accent); + box-shadow: 0 2px 8px rgba(var(--accent-rgb), 0.5); +} + +.explorer-node-select svg { + width: 12px; + height: 12px; + color: #fff; + opacity: 0; +} + +.explorer-node-select.active svg { + opacity: 1; +} + +/* Badge floating on album node */ +.explorer-node-badge-float { + position: absolute; + bottom: 50%; + left: 50%; + transform: translateX(-50%); + font-size: 7px; + font-weight: 800; + padding: 2px 6px; + border-radius: 4px; + text-transform: uppercase; + letter-spacing: 0.5px; + z-index: 5; + white-space: nowrap; +} + +.explorer-node-badge-float.owned { + background: rgba(76, 175, 80, 0.85); + color: #fff; +} + +.explorer-node-badge-float.playlist { + background: rgba(var(--accent-rgb), 0.85); + color: #fff; + top: 6px; + left: 6px; + bottom: auto; + font-size: 10px; + padding: 1px 5px; +} + +/* โ”€โ”€ Track Node โ”€โ”€ */ +.explorer-node-track { + width: auto; + height: auto; + border-radius: 10px; + overflow: visible; + padding: 6px 14px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.06); + cursor: default; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +.explorer-node-track:hover { + transform: none; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); + border-color: rgba(255, 255, 255, 0.1); +} + +.explorer-node-track .explorer-node-label { + position: static; + background: none; + padding: 0; + text-align: left; +} + +.explorer-node-track .explorer-node-label-main { + font-size: 11px; + font-weight: 500; + white-space: nowrap; +} + +.explorer-node-track .explorer-node-label-meta { + font-size: 9px; +} + +/* โ”€โ”€ SVG Connection Lines โ”€โ”€ */ +.explorer-svg { + transition: opacity 0.4s ease; + opacity: 0; +} + +.explorer-tree:hover .explorer-svg { + opacity: 1; +} + +.explorer-line { + fill: none; +} + +.explorer-line-animated { + animation: explorer-line-draw 0.6s cubic-bezier(0.4, 0, 0.2, 1) forwards; +} + +@keyframes explorer-line-draw { + to { stroke-dashoffset: 0; } +} + +@keyframes explorer-node-enter { + from { opacity: 0; transform: translateY(20px) scale(0.8); } + to { opacity: 1; transform: none; } +} + +/* โ”€โ”€ Responsive โ”€โ”€ */ +@media (max-width: 900px) { + .explorer-container { padding: 16px; } + + .explorer-controls { width: 100%; flex-wrap: wrap; } + .explorer-node-root { width: 120px; height: 120px; } + .explorer-node-artist { width: 68px; height: 68px; } + .explorer-node-album { width: 72px; height: 72px; } + .explorer-tier { gap: 12px; } +} + +@media (max-width: 600px) { + .explorer-action-bar { flex-direction: column; gap: 10px; text-align: center; } + .explorer-node-root { width: 100px; height: 100px; } + .explorer-node-artist { width: 56px; height: 56px; } + .explorer-node-album { width: 60px; height: 60px; } +} + display: flex; + align-items: center; + gap: 20px; + padding: 24px 32px; + background: linear-gradient(135deg, rgba(var(--accent-rgb), 0.1) 0%, rgba(var(--accent-rgb), 0.03) 100%); + border: 1px solid rgba(var(--accent-rgb), 0.15); + border-radius: 20px; + position: relative; + overflow: hidden; + backdrop-filter: blur(16px); + animation: explorer-node-enter 0.6s ease both; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.06); +} + +.explorer-root-glow { + position: absolute; + width: 200px; + height: 200px; + background: radial-gradient(circle, rgba(var(--accent-rgb), 0.15) 0%, transparent 70%); + top: -80px; + left: -40px; + pointer-events: none; +} + +.explorer-root-image { + width: 72px; + height: 72px; + border-radius: 14px; + object-fit: cover; + box-shadow: 0 6px 24px rgba(0, 0, 0, 0.5); + border: 2px solid rgba(255, 255, 255, 0.08); + flex-shrink: 0; + z-index: 1; +} + +.explorer-root-image-placeholder { + width: 72px; + height: 72px; + border-radius: 14px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(var(--accent-rgb), 0.15); + color: rgba(255, 255, 255, 0.3); + font-size: 28px; + flex-shrink: 0; + z-index: 1; +} + +.explorer-root-info { z-index: 1; } + +.explorer-root-label { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 1.5px; + color: rgba(var(--accent-rgb), 0.8); + margin-bottom: 4px; +} + +.explorer-root-name { + font-size: 22px; + font-weight: 800; + color: #fff; + letter-spacing: -0.3px; +} + +.explorer-root-meta { + font-size: 12px; + color: rgba(255, 255, 255, 0.4); + margin-top: 4px; +} + +/* Trunk โ€” vertical line from root to artists */ +.explorer-trunk { + width: 2px; + height: 32px; + background: linear-gradient(to bottom, rgba(var(--accent-rgb), 0.4), rgba(var(--accent-rgb), 0.15)); + margin: 0 auto; + border-radius: 2px; + animation: explorer-trunk-grow 0.4s ease 0.3s both; +} + +@keyframes explorer-trunk-grow { + from { height: 0; opacity: 0; } + to { height: 32px; opacity: 1; } +} + +/* Artist column โ€” vertical list */ +.explorer-artists-column { + display: flex; + flex-direction: column; + gap: 4px; + width: 100%; +} + +/* Artist row โ€” connector + card + albums */ +.explorer-artist-row { + animation: explorer-node-enter 0.5s ease both; +} + +/* Left connector (dot + horizontal line) */ +.explorer-artist-connector { + display: flex; + align-items: center; + padding-left: 48px; + height: 0; + position: relative; +} + +.explorer-connector-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--accent); + box-shadow: 0 0 8px rgba(var(--accent-rgb), 0.4); + flex-shrink: 0; + position: relative; + z-index: 2; +} + +.explorer-connector-line { + height: 2px; + flex: 1; + max-width: 32px; + background: linear-gradient(to right, rgba(var(--accent-rgb), 0.4), rgba(var(--accent-rgb), 0.1)); + border-radius: 2px; +} + +/* Artist card โ€” horizontal layout */ +.explorer-artist-card { + display: flex; + align-items: center; + gap: 16px; + padding: 14px 20px; + margin: 0 16px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 14px; + cursor: pointer; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; +} + +.explorer-artist-card:hover { + background: rgba(255, 255, 255, 0.06); + border-color: rgba(var(--accent-rgb), 0.2); + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2); +} + +.explorer-artist-card.expanded { + border-color: rgba(var(--accent-rgb), 0.35); + background: rgba(var(--accent-rgb), 0.04); + box-shadow: 0 4px 24px rgba(var(--accent-rgb), 0.08); + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; +} + +.explorer-artist-card.error { + border-color: rgba(255, 71, 87, 0.15); + opacity: 0.6; + cursor: default; +} + +.explorer-artist-card.empty { + opacity: 0.5; + cursor: default; +} + +.explorer-artist-card-left { flex-shrink: 0; } + +.explorer-artist-avatar { + width: 52px; + height: 52px; + border-radius: 50%; + overflow: hidden; + border: 2px solid rgba(255, 255, 255, 0.08); + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3); +} + +.explorer-artist-avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.explorer-artist-avatar span { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background: rgba(var(--accent-rgb), 0.12); + color: rgba(255, 255, 255, 0.4); + font-size: 20px; + font-weight: 700; +} + +.explorer-artist-card-center { + flex: 1; + min-width: 0; +} + +.explorer-artist-card-name { + font-size: 15px; + font-weight: 700; + color: #fff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.explorer-artist-card-tracks { + font-size: 11px; + color: rgba(255, 255, 255, 0.35); + margin-top: 3px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-style: italic; +} + +.explorer-artist-card-error { + font-size: 11px; + color: rgba(255, 71, 87, 0.7); + margin-top: 3px; +} + +.explorer-artist-card-right { + display: flex; + align-items: center; + gap: 16px; + flex-shrink: 0; +} + +.explorer-artist-stat { + font-size: 11px; + color: rgba(255, 255, 255, 0.35); + white-space: nowrap; +} + +.explorer-artist-stat strong { + color: rgba(255, 255, 255, 0.7); + font-weight: 700; +} + +.explorer-expand-indicator { + font-size: 14px; + color: rgba(255, 255, 255, 0.3); + transition: transform 0.3s; + width: 20px; + text-align: center; +} + +.explorer-artist-card.expanded .explorer-expand-indicator { + color: var(--accent); +} + +/* Albums container (expands below artist card) */ +.explorer-albums-container { + max-height: 0; + overflow: hidden; + transition: max-height 0.5s cubic-bezier(0.4, 0, 0.2, 1); + margin: 0 16px; + border-left: 2px solid transparent; +} + +.explorer-albums-container.open { + max-height: 4000px; + border-left-color: rgba(var(--accent-rgb), 0.15); + margin-left: 56px; + padding: 12px 0 12px 24px; +} + +/* Album nodes โ€” horizontal flow */ +.explorer-album-nodes { + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +/* Individual album node */ +.explorer-album-node { + display: flex; + align-items: stretch; + animation: explorer-node-enter 0.4s ease both; + position: relative; +} + +.explorer-album-node-connector { + width: 20px; + display: flex; + align-items: center; + position: relative; +} + +.explorer-album-node-connector::before { + content: ''; + width: 100%; + height: 2px; + background: rgba(var(--accent-rgb), 0.15); + border-radius: 2px; +} + +.explorer-album-node-connector::after { + content: ''; + position: absolute; + right: 0; + width: 6px; + height: 6px; + border-radius: 50%; + background: rgba(var(--accent-rgb), 0.3); + transform: translateX(50%); +} + +.explorer-album-node-card { + width: 160px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 12px; + overflow: hidden; + cursor: pointer; + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); +} + +.explorer-album-node:hover .explorer-album-node-card { + border-color: rgba(var(--accent-rgb), 0.3); + transform: translateY(-2px); + box-shadow: 0 8px 28px rgba(0, 0, 0, 0.35); +} + +.explorer-album-node.selected .explorer-album-node-card { + border-color: rgba(var(--accent-rgb), 0.6); + box-shadow: 0 0 0 1px rgba(var(--accent-rgb), 0.2), 0 4px 20px rgba(var(--accent-rgb), 0.12); +} + +.explorer-album-node.owned { + opacity: 0.5; +} + +.explorer-album-node.selected .explorer-album-node-connector::before, +.explorer-album-node.selected .explorer-album-node-connector::after { + background: var(--accent); +} + +/* Album node art */ +.explorer-album-node-art { + position: relative; + width: 100%; + aspect-ratio: 1; + overflow: hidden; + background: rgba(var(--accent-rgb), 0.05); +} + +.explorer-album-node-art img { + width: 100%; + height: 100%; + object-fit: cover; + transition: transform 0.4s; +} + +.explorer-album-node:hover .explorer-album-node-art img { + transform: scale(1.06); +} + +.explorer-album-node-art-placeholder { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + color: rgba(255, 255, 255, 0.15); + font-size: 32px; +} + +.explorer-album-node-check { + position: absolute; + top: 8px; + right: 8px; + width: 26px; + height: 26px; + border-radius: 8px; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); + border: 2px solid rgba(255, 255, 255, 0.25); + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; + opacity: 0; +} + +.explorer-album-node:hover .explorer-album-node-check, +.explorer-album-node-check.checked { + opacity: 1; +} + +.explorer-album-node-check.checked { + background: var(--accent); + border-color: var(--accent); + box-shadow: 0 2px 8px rgba(var(--accent-rgb), 0.4); +} + +.explorer-album-node-check svg { + width: 14px; + height: 14px; + color: #fff; + opacity: 0; +} + +.explorer-album-node-check.checked svg { + opacity: 1; +} + +/* Album node info */ +.explorer-album-node-info { + padding: 10px 12px 12px; +} + +.explorer-album-node-title { + font-size: 12px; + font-weight: 600; + color: rgba(255, 255, 255, 0.9); + line-height: 1.3; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.explorer-album-node-meta { + font-size: 10px; + color: rgba(255, 255, 255, 0.3); + margin-top: 4px; +} + +.explorer-album-node-badges { + display: flex; + gap: 4px; + margin-top: 6px; + flex-wrap: wrap; +} + +.explorer-node-badge { + font-size: 9px; + font-weight: 700; + padding: 2px 7px; + border-radius: 4px; + text-transform: uppercase; + letter-spacing: 0.4px; +} + +.explorer-node-badge.owned { + background: rgba(76, 175, 80, 0.2); + color: rgba(76, 175, 80, 0.9); +} + +.explorer-node-badge.playlist { + background: rgba(var(--accent-rgb), 0.2); + color: rgba(var(--accent-rgb), 0.9); +} + +/* SVG connection lines */ +.explorer-connection-line { + stroke: rgba(var(--accent-rgb), 0.2); + stroke-width: 2; + fill: none; + animation: explorer-line-draw 0.6s ease forwards; + filter: drop-shadow(0 0 2px rgba(var(--accent-rgb), 0.1)); +} + +@keyframes explorer-line-draw { + from { stroke-dashoffset: var(--line-length, 500); } + to { stroke-dashoffset: 0; } +} + +@keyframes explorer-node-enter { + from { opacity: 0; transform: translateY(16px) scale(0.92) rotate(-2deg); } + 60% { transform: translateY(-2px) scale(1.02) rotate(0.5deg); } + to { opacity: 1; transform: none; } +} + +/* Responsive */ +@media (max-width: 900px) { + .explorer-container { padding: 16px; } + + .explorer-controls { width: 100%; } + .explorer-root-node { padding: 16px 20px; gap: 14px; } + .explorer-root-name { font-size: 18px; } + .explorer-root-image { width: 56px; height: 56px; } + .explorer-artist-card { margin: 0 8px; padding: 12px 14px; gap: 12px; } + .explorer-artist-card-right { gap: 10px; } + .explorer-albums-container.open { margin-left: 40px; padding-left: 16px; } + .explorer-album-node-card { width: 130px; } +} + +@media (max-width: 600px) { + .explorer-action-bar { flex-direction: column; gap: 10px; text-align: center; } + .explorer-artist-avatar { width: 40px; height: 40px; } + .explorer-artist-card-right { display: none; } + .explorer-album-nodes { gap: 8px; } + .explorer-album-node-card { width: 110px; } + .explorer-connector-dot { display: none; } + .explorer-connector-line { display: none; } +}