Add Playlist Explorer — visual discovery tree for expanding playlists

New dedicated Explorer page with interactive node graph visualization.
Users select a mirrored playlist, choose Albums or Discographies mode,
and the app builds a branching tree: playlist root → artist nodes →
album nodes → track nodes. Supports all metadata sources (Spotify,
iTunes, Deezer) with source-aware discovery cache integration.

Features:
- Streaming NDJSON builds tree progressively as artist data arrives
- Circular artist nodes with photos, rounded album nodes with art
- SVG bezier connections that draw in on completion, fade on hover
- Click artist to expand albums, double-click album for track listing
- Single-click albums to select, Select All/Deselect for bulk ops
- Wishlist confirmation modal with per-album progress (NDJSON streaming)
- Artist nodes glow when any of their albums are selected
- Playlist picker with source tabs, discovery % gate (50% minimum)
- Zoom (scroll/pinch/buttons), pan (right/middle-drag), fit-to-view
- Metadata cache for discographies and album track listings
- Owned album detection from library database
- Fallback track-name matching when album names are missing
This commit is contained in:
Broque Thomas 2026-03-30 17:13:30 -07:00
parent fde1a7d77e
commit 69346ec313
5 changed files with 2923 additions and 0 deletions

View file

@ -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/<album_id>', 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 = []

View file

@ -186,6 +186,10 @@
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polygon points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76" fill="currentColor" opacity="0.2" stroke="currentColor"/></svg></span>
<span class="nav-text">Discover</span>
</button>
<button class="nav-button" data-page="playlist-explorer">
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="5" r="3"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="12" x2="5" y2="18"/><line x1="12" y1="12" x2="19" y2="18"/><circle cx="5" cy="19" r="2"/><circle cx="19" cy="19" r="2"/><line x1="12" y1="12" x2="12" y2="18"/><circle cx="12" cy="19" r="2"/></svg></span>
<span class="nav-text">Explorer</span>
</button>
<button class="nav-button" data-page="artists">
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg></span>
<span class="nav-text">Artists</span>
@ -3548,6 +3552,87 @@
</div>
</div>
<!-- Playlist Explorer Page -->
<div class="page" id="playlist-explorer-page">
<div class="explorer-container">
<!-- Header (compact) -->
<div class="dashboard-header" style="margin-bottom: 12px;">
<div class="header-text">
<h2 class="header-title"><img src="/static/explorer.png" class="page-header-icon" alt=""><span>Playlist Explorer</span></h2>
</div>
</div>
<!-- Playlist picker + controls inline -->
<div class="explorer-playlist-picker" id="explorer-playlist-picker">
<div class="explorer-picker-top">
<div class="explorer-picker-tabs" id="explorer-picker-tabs"></div>
<div class="explorer-controls">
<div class="explorer-mode-toggle">
<button class="explorer-mode-btn active" data-mode="albums"
onclick="explorerSetMode('albums')">Albums</button>
<button class="explorer-mode-btn" data-mode="discographies"
onclick="explorerSetMode('discographies')">Discographies</button>
</div>
<button class="explorer-build-btn" id="explorer-build-btn"
onclick="explorerBuildTree()">Explore</button>
</div>
</div>
<div class="explorer-picker-scroll" id="explorer-picker-scroll">
<!-- Populated by JS -->
</div>
</div>
<!-- Action bar (sticky, appears after tree built) -->
<div class="explorer-action-bar" id="explorer-action-bar" style="display: none;">
<span class="explorer-selection-count" id="explorer-selection-count">0 albums selected</span>
<div class="explorer-action-buttons">
<button class="explorer-action-btn" onclick="explorerSelectAll()">Select All</button>
<button class="explorer-action-btn" onclick="explorerDeselectAll()">Deselect</button>
<button class="explorer-action-btn primary" onclick="explorerAddToWishlist()">
Add to Wishlist</button>
</div>
<span class="explorer-nav-hint">Scroll to zoom &middot; Right-drag to pan &middot; Double-click album for tracks</span>
</div>
<!-- Tree viewport -->
<div class="explorer-viewport" id="explorer-viewport">
<div class="explorer-zoom-controls">
<button class="explorer-zoom-btn" onclick="explorerZoom(0.15)" title="Zoom in">+</button>
<button class="explorer-zoom-btn" onclick="explorerZoom(-0.15)" title="Zoom out">&minus;</button>
<button class="explorer-zoom-btn" onclick="explorerFitToView()" title="Fit to view">&#11036;</button>
<button class="explorer-zoom-btn" onclick="_explorer._zoom=1; explorerZoom(0)" title="Reset zoom">1:1</button>
</div>
<div class="explorer-tree" id="explorer-tree">
<svg class="explorer-svg" id="explorer-svg"></svg>
<!-- Empty state -->
<div class="explorer-empty" id="explorer-empty">
<div class="explorer-empty-icon">
<svg viewBox="0 0 80 80" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="40" cy="12" r="8"/>
<line x1="40" y1="20" x2="40" y2="35"/>
<line x1="40" y1="35" x2="18" y2="55"/>
<line x1="40" y1="35" x2="62" y2="55"/>
<circle cx="18" cy="60" r="6"/>
<circle cx="40" cy="60" r="6"/>
<circle cx="62" cy="60" r="6"/>
<line x1="40" y1="35" x2="40" y2="54"/>
</svg>
</div>
<p class="explorer-empty-title">Select a playlist to explore</p>
<p class="explorer-empty-desc">Choose a mirrored playlist and mode above, then click Explore to build the discovery tree</p>
</div>
</div>
</div>
<!-- Building progress -->
<div class="explorer-progress" id="explorer-progress" style="display: none;">
<div class="explorer-progress-bar">
<div class="explorer-progress-fill" id="explorer-progress-fill"></div>
</div>
<span class="explorer-progress-text" id="explorer-progress-text">Building tree...</span>
</div>
</div>
</div>
<!-- Settings Page -->
<div class="page" id="settings-page">
<div class="dashboard-header">

View file

@ -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' },

View file

@ -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 = '<div class="explorer-picker-empty">No mirrored playlists found. Sync a playlist first.</div>';
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 `<button class="explorer-picker-tab ${i === 0 ? 'active' : ''}" data-source="${src}" onclick="explorerSwitchPickerTab('${src}')">${label} <span class="explorer-picker-tab-count">${count}</span></button>`;
}).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 `
<div class="explorer-picker-card ${isActive ? 'active' : ''} ${!isReady ? 'not-ready' : ''}"
data-id="${p.id}"
onclick="${isReady ? `explorerSelectPlaylist(${p.id}, this)` : `explorerRedirectToDiscover(${p.id})`}">
<div class="explorer-picker-card-art">
${img ? `<img src="${img}" alt="" loading="lazy">` : '<div class="explorer-picker-card-art-placeholder">&#9835;</div>'}
</div>
<div class="explorer-picker-card-info">
<div class="explorer-picker-card-name">${p.name || 'Untitled'}</div>
<div class="explorer-picker-card-meta">${total} tracks &middot; ${isReady ? `${pct}% discovered` : `<span class="explorer-picker-not-ready">${pct}% — needs discovery</span>`}</div>
</div>
${isReady ? '' : '<div class="explorer-picker-card-lock" title="Less than 50% discovered — click to go discover">&#128274;</div>'}
</div>
`;
}).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 = '<svg class="explorer-svg" id="explorer-svg"></svg>';
_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 = `
<div class="explorer-tier explorer-tier-root">
<div class="explorer-node explorer-node-root" id="explorer-root">
<div class="explorer-node-glow"></div>
${meta.playlist_image
? `<img class="explorer-node-img" src="${meta.playlist_image}" alt="">`
: '<div class="explorer-node-img-placeholder">&#9835;</div>'
}
<div class="explorer-node-label">
<div class="explorer-node-label-sub">SOURCE</div>
<div class="explorer-node-label-main">${meta.playlist_name}</div>
<div class="explorer-node-label-meta">${meta.total_tracks} tracks &middot; ${meta.total_artists} artists</div>
</div>
</div>
</div>
<div class="explorer-artist-tiers" id="explorer-artist-tiers"></div>
`;
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 = `
<div class="explorer-branch" id="explorer-branch-${safeKey}" style="--enter-delay: ${(index % 5) * 0.1}s">
<div class="explorer-node explorer-node-artist ${hasError ? 'error' : ''}"
id="explorer-node-${safeKey}" data-key="${safeKey}"
onclick="${hasError || albumCount === 0 ? '' : `explorerToggleArtist('${safeKey}')`}">
${artist.image_url
? `<img class="explorer-node-img" src="${artist.image_url}" alt="" loading="lazy">`
: ''
}
<div class="explorer-node-label">
<div class="explorer-node-label-main">${artist.name || 'Unknown'}</div>
<div class="explorer-node-label-meta">${hasError ? 'Not found' : albumCount + ' album' + (albumCount !== 1 ? 's' : '')}</div>
</div>
${!hasError && albumCount > 0 ? '<div class="explorer-node-expand-hint">&#9662;</div>' : ''}
${hasError ? '<div class="explorer-node-error-ring"></div>' : ''}
</div>
<div class="explorer-children" id="explorer-children-${safeKey}"></div>
</div>
`;
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 `
<div class="explorer-branch" style="--enter-delay: ${i * 0.06}s">
<div class="explorer-node explorer-node-album ${selected ? 'selected' : ''} ${owned ? 'owned' : ''} ${inPlaylist ? 'in-playlist' : ''}"
data-id="${id}" data-key="${id}"
onclick="explorerToggleAlbum('${id}'); event.stopPropagation();"
title="${(album.title || '').replace(/"/g, '&quot;')}\n${album.year || ''} · ${typeLabel} · ${album.track_count || '?'} tracks${owned ? '\n✓ Already in library' : ''}${inPlaylist ? '\n♫ Track from this playlist' : ''}\nClick to select · Double-click for tracklist">
${album.image_url
? `<img class="explorer-node-img" src="${album.image_url}" alt="" loading="lazy">`
: ''
}
<div class="explorer-node-label">
<div class="explorer-node-label-main">${album.title || 'Unknown'}</div>
<div class="explorer-node-label-meta">${album.year || ''} &middot; ${album.track_count || '?'} tracks</div>
</div>
<div class="explorer-node-select ${selected ? 'active' : ''}">
<svg viewBox="0 0 20 20"><polyline points="4 10 8 14 16 6" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</div>
${owned ? '<div class="explorer-node-badge-float owned">Owned</div>' : ''}
${inPlaylist ? '<div class="explorer-node-badge-float playlist">♫</div>' : ''}
</div>
<div class="explorer-children" id="explorer-tracks-${id}"></div>
</div>
`;
}).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) => `
<div class="explorer-branch" style="--enter-delay: ${i * 0.03}s">
<div class="explorer-node explorer-node-track">
<div class="explorer-node-label">
<div class="explorer-node-label-main">${t.track_number}. ${t.name}</div>
<div class="explorer-node-label-meta">${_formatDuration(t.duration_ms)}</div>
</div>
</div>
</div>
`).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 += `<div class="discog-section-header">${_esc(section.name)}</div>`;
section.albums.forEach((album, i) => {
const year = album.year || '';
const typeLabel = album.album_type === 'single' ? 'Single' : album.album_type === 'ep' ? 'EP' : 'Album';
cardsHtml += `
<label class="discog-card ${album.owned ? 'owned' : ''}" data-type="${album.album_type || 'album'}" data-artist-id="${section.artistId}" style="animation-delay:${i * 0.03}s">
<input type="checkbox" class="discog-card-cb" data-album-id="${album.spotify_id}" data-tracks="${album.track_count || 0}" ${!album.owned ? 'checked' : ''} onchange="_explorerWishlistUpdateCount()">
<div class="discog-card-art">
${album.image_url ? `<img src="${album.image_url}" alt="" loading="lazy">` : '<div class="discog-card-art-placeholder">&#9835;</div>'}
${album.owned ? '<span class="discog-card-status">&#10003;</span>' : ''}
</div>
<div class="discog-card-info">
<div class="discog-card-title">${_esc(album.title || 'Unknown')}</div>
<div class="discog-card-meta">${year}${year ? ' · ' : ''}${typeLabel} · ${album.track_count || '?'} tracks</div>
</div>
<div class="discog-card-check"></div>
</label>
`;
});
});
overlay.innerHTML = `
<div class="discog-modal">
<div class="discog-modal-hero" style="background-image:url('${artistSections[0]?.image || ''}')">
<div class="discog-modal-hero-overlay"></div>
<div class="discog-modal-hero-content">
<h2 class="discog-modal-title">Add to Wishlist</h2>
<p class="discog-modal-artist">${artistSections.length} artist${artistSections.length !== 1 ? 's' : ''} · ${totalAlbums} releases</p>
</div>
<button class="discog-modal-close" onclick="document.getElementById('explorer-wishlist-overlay')?.remove()">&times;</button>
</div>
<div class="discog-filter-bar">
<div class="discog-filters">
<button class="discog-filter active" data-type="album" onclick="_explorerWishlistToggleFilter(this)">Albums</button>
<button class="discog-filter active" data-type="ep" onclick="_explorerWishlistToggleFilter(this)">EPs</button>
<button class="discog-filter active" data-type="single" onclick="_explorerWishlistToggleFilter(this)">Singles</button>
</div>
<div class="discog-select-actions">
<button class="discog-select-btn" onclick="document.querySelectorAll('#explorer-wishlist-overlay .discog-card-cb').forEach(c=>{c.checked=true}); _explorerWishlistUpdateCount()">Select All</button>
<button class="discog-select-btn" onclick="document.querySelectorAll('#explorer-wishlist-overlay .discog-card-cb').forEach(c=>{c.checked=false}); _explorerWishlistUpdateCount()">Deselect</button>
</div>
</div>
<div class="discog-grid" id="explorer-wishlist-grid">${cardsHtml}</div>
<div class="discog-progress" id="explorer-wishlist-progress" style="display:none;"></div>
<div class="discog-footer" id="explorer-wishlist-footer">
<div class="discog-footer-info" id="explorer-wishlist-info"></div>
<div class="discog-footer-actions">
<button class="discog-cancel-btn" onclick="document.getElementById('explorer-wishlist-overlay')?.remove()">Cancel</button>
<button class="discog-submit-btn" id="explorer-wishlist-submit">
<span class="discog-submit-icon">&#11015;</span>
<span id="explorer-wishlist-submit-text">Add to Wishlist</span>
</button>
</div>
</div>
</div>
`;
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 = `
<div class="discog-prog-art">${album.img ? `<img src="${album.img}">` : '&#9835;'}</div>
<div class="discog-prog-info">
<div class="discog-prog-title">${_esc(album.title)}</div>
<div class="discog-prog-status">Waiting...</div>
</div>
<div class="discog-prog-icon"><div class="discog-spinner"></div></div>
`;
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 = '<span style="color:#4CAF50">&#10003;</span>';
item.classList.remove('active');
item.classList.add('done');
} else if (result.status === 'error') {
if (statusEl) statusEl.textContent = result.message || 'Error';
if (iconEl) iconEl.innerHTML = '<span style="color:#ff4757">&#10007;</span>';
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 = `
<linearGradient id="explorer-grad-root" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="rgba(${accentRgb}, 0.25)"/>
<stop offset="100%" stop-color="rgba(${accentRgb}, 0.06)"/>
</linearGradient>
<linearGradient id="explorer-grad-album" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="rgba(${accentRgb}, 0.15)"/>
<stop offset="100%" stop-color="rgba(${accentRgb}, 0.04)"/>
</linearGradient>
`;
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);
});

File diff suppressed because it is too large Load diff