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:
parent
fde1a7d77e
commit
69346ec313
5 changed files with 2923 additions and 0 deletions
372
web_server.py
372
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/<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 = []
|
||||
|
|
|
|||
|
|
@ -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 · Right-drag to pan · 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">−</button>
|
||||
<button class="explorer-zoom-btn" onclick="explorerFitToView()" title="Fit to view">⬜</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">
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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">♫</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 · ${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">🔒</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">♫</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 · ${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">▾</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, '"')}\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 || ''} · ${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">♫</div>'}
|
||||
${album.owned ? '<span class="discog-card-status">✓</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()">×</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">⬇</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}">` : '♫'}</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">✓</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">✗</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
Loading…
Reference in a new issue