From 5f58432ca45dd580f94c459b2ad92e1153d175c3 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Mon, 9 Mar 2026 10:13:42 -0700 Subject: [PATCH] Redesigned media player with expanded Now Playing modal and smart radio --- database/music_database.py | 166 ++++++++++++++++ web_server.py | 42 +++++ webui/index.html | 19 +- webui/static/script.js | 378 +++++++++++++++++++++++++++++++++++-- webui/static/style.css | 180 ++++++++++++++++-- 5 files changed, 750 insertions(+), 35 deletions(-) diff --git a/database/music_database.py b/database/music_database.py index d1991aab..94444049 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -7293,6 +7293,172 @@ class MusicDatabase: logger.error(f"Error clearing automation run history: {e}") return 0 + def get_radio_tracks(self, track_id, limit=20, exclude_ids=None) -> Dict[str, Any]: + """Find similar tracks for radio mode auto-play queue. + + Strategy (each tier capped to ensure diversity): + 1. Same artist, different albums (max 30% of limit) + 2. Same genre — from album genres + artist genres (other artists) + 3. Same mood / style — from album + artist metadata + 4. Random library tracks (fallback) + + Args: + track_id: The seed track ID. + limit: Maximum number of tracks to return. + exclude_ids: Optional list of track IDs to exclude. + + Returns: + dict with ``success``, ``tracks`` list. + """ + try: + with self._get_connection() as conn: + cursor = conn.cursor() + + # Resolve the seed track and its album / artist + cursor.execute(""" + SELECT t.id, t.artist_id, t.album_id, + al.genres AS album_genres, + al.mood AS album_mood, + al.style AS album_style, + ar.name AS artist_name, + ar.genres AS artist_genres, + ar.mood AS artist_mood, + ar.style AS artist_style + FROM tracks t + JOIN albums al ON al.id = t.album_id + JOIN artists ar ON ar.id = t.artist_id + WHERE t.id = ? + """, (track_id,)) + seed = cursor.fetchone() + if not seed: + return {'success': False, 'error': f'Track {track_id} not found'} + + seed = dict(seed) + artist_name = seed['artist_name'] + + # Build the set of IDs to exclude (seed + caller-supplied) + excluded = {str(track_id)} + if exclude_ids: + excluded.update(str(eid) for eid in exclude_ids) + + collected: list[dict] = [] + seen_ids: set[str] = set(excluded) + + def _exclude_placeholders(): + return ','.join('?' * len(seen_ids)) + + def _exclude_values(): + return list(seen_ids) + + _track_select = """ + SELECT t.id, t.title, t.track_number, t.duration, + t.file_path, t.bitrate, + t.album_id, t.artist_id, + al.title AS album, + COALESCE(al.thumb_url, ar.thumb_url) AS image_url, + ar.name AS artist + FROM tracks t + JOIN albums al ON al.id = t.album_id + JOIN artists ar ON ar.id = t.artist_id + """ + + def _collect(rows, cap=None): + """Append rows to collected. Stop at cap or limit.""" + target = min(limit, (len(collected) + cap)) if cap else limit + for row in rows: + r = dict(row) + rid = str(r['id']) + if rid not in seen_ids: + seen_ids.add(rid) + collected.append(r) + if len(collected) >= target: + return True + return len(collected) >= limit + + def _parse_tags(raw_val): + """Parse a JSON array or comma-separated string into a list.""" + if not raw_val: + return [] + try: + parsed = json.loads(raw_val) + return parsed if isinstance(parsed, list) else [str(parsed)] + except (json.JSONDecodeError, ValueError): + return [t.strip() for t in raw_val.split(',') if t.strip()] + + # --- 1. Same artist, different albums (capped at 30% of limit) --- + same_artist_cap = max(5, limit * 3 // 10) + cursor.execute(f""" + {_track_select} + WHERE ar.name = ? AND t.album_id != ? AND t.id NOT IN ({_exclude_placeholders()}) + ORDER BY RANDOM() + LIMIT ? + """, [artist_name, seed['album_id']] + _exclude_values() + [same_artist_cap]) + _collect(cursor.fetchall(), cap=same_artist_cap) + + if len(collected) >= limit: + return {'success': True, 'tracks': collected} + + # --- 2. Same genre (album genres + artist genres, other artists) --- + genre_list = _parse_tags(seed.get('album_genres')) + artist_genre_list = _parse_tags(seed.get('artist_genres')) + all_genres = list(dict.fromkeys(genre_list + artist_genre_list)) # dedupe, preserve order + + if all_genres: + genre_conditions = ' OR '.join( + ['al.genres LIKE ?' for _ in all_genres] + + ['ar.genres LIKE ?' for _ in all_genres] + ) + genre_params = [f'%{g}%' for g in all_genres] * 2 + cursor.execute(f""" + {_track_select} + WHERE ({genre_conditions}) + AND ar.name != ? + AND t.id NOT IN ({_exclude_placeholders()}) + ORDER BY RANDOM() + LIMIT ? + """, genre_params + [artist_name] + _exclude_values() + [limit - len(collected)]) + if _collect(cursor.fetchall()): + return {'success': True, 'tracks': collected} + + # --- 3. Same mood / style (album + artist level) --- + for field_name in ('mood', 'style'): + album_tags = _parse_tags(seed.get(f'album_{field_name}')) + artist_tags = _parse_tags(seed.get(f'artist_{field_name}')) + all_tags = list(dict.fromkeys(album_tags + artist_tags)) + + if all_tags: + tag_conditions = ' OR '.join( + [f'al.{field_name} LIKE ?' for _ in all_tags] + + [f'ar.{field_name} LIKE ?' for _ in all_tags] + ) + tag_params = [f'%{t}%' for t in all_tags] * 2 + cursor.execute(f""" + {_track_select} + WHERE ({tag_conditions}) + AND ar.name != ? + AND t.id NOT IN ({_exclude_placeholders()}) + ORDER BY RANDOM() + LIMIT ? + """, tag_params + [artist_name] + _exclude_values() + [limit - len(collected)]) + if _collect(cursor.fetchall()): + return {'success': True, 'tracks': collected} + + # --- 4. Random library tracks --- + if len(collected) < limit: + cursor.execute(f""" + {_track_select} + WHERE t.id NOT IN ({_exclude_placeholders()}) + ORDER BY RANDOM() + LIMIT ? + """, _exclude_values() + [limit - len(collected)]) + _collect(cursor.fetchall()) + + return {'success': True, 'tracks': collected} + + except Exception as e: + logger.error(f"Error getting radio tracks for track {track_id}: {e}") + return {'success': False, 'error': str(e)} + # Thread-safe singleton pattern for database access _database_instances: Dict[int, MusicDatabase] = {} # Thread ID -> Database instance _database_lock = threading.Lock() diff --git a/web_server.py b/web_server.py index 727896ae..93c1dad8 100644 --- a/web_server.py +++ b/web_server.py @@ -9298,6 +9298,32 @@ def library_delete_tracks_batch(): return jsonify({"success": False, "error": str(e)}), 500 +@app.route('/api/library/radio') +def library_radio(): + """Get a smart queue of similar tracks for radio mode auto-play.""" + try: + track_id = request.args.get('track_id') + if not track_id: + return jsonify({"success": False, "error": "track_id is required"}), 400 + + limit = request.args.get('limit', 20, type=int) + exclude_raw = request.args.get('exclude', '') + exclude_ids = [eid.strip() for eid in exclude_raw.split(',') if eid.strip()] if exclude_raw else None + + database = get_database() + result = database.get_radio_tracks(track_id, limit=limit, exclude_ids=exclude_ids) + + if not result.get('success'): + return jsonify(result), 404 + + return jsonify(result) + except Exception as e: + print(f"Error getting radio tracks: {e}") + import traceback + traceback.print_exc() + return jsonify({"success": False, "error": str(e)}), 500 + + # ==================== End Enhanced Library Management ==================== @app.route('/api/stream/start', methods=['POST']) @@ -14121,6 +14147,22 @@ def get_version_info(): "title": "What's New in SoulSync", "subtitle": "Version 1.8 — Latest Changes", "sections": [ + { + "title": "🎵 Now Playing Overhaul", + "description": "Redesigned media player with expanded Now Playing modal and smart radio", + "features": [ + "• Expanded Now Playing modal — click the sidebar player to open a full-screen playback experience", + "• Album art ambient glow — dominant color extracted from cover art tints the modal background", + "• Smart Radio mode — toggle on to auto-queue up to 50 similar tracks based on genre, mood, style, and artist", + "• Queue system — add tracks from the Enhanced Library Manager, manage queue in the Now Playing modal", + "• Web Audio visualizer — real frequency-driven bars responding to actual audio playback", + "• Repeat modes — off, repeat-all (loop queue), and repeat-one with shuffle support", + "• Redesigned sidebar player — always-visible controls with album art thumbnail and progress bar", + "• Media Session API — OS-level media controls with prev/next track support", + "• Keyboard shortcuts — Space (play/pause), arrows (seek/volume), M (mute), Escape (close)", + "• Track transition animations and buffering state indicator" + ] + }, { "title": "📚 Enhanced Library Manager", "description": "Professional-grade library management view on the artist detail page", diff --git a/webui/index.html b/webui/index.html index 7dc7ac65..afed8efe 100644 --- a/webui/index.html +++ b/webui/index.html @@ -4084,7 +4084,9 @@