Compare commits
2 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ab9301a18 | ||
|
|
5d5aac9be3 |
10 changed files with 247 additions and 1220 deletions
|
|
@ -227,14 +227,10 @@ class DeezerDownloadClient(DownloadSourcePlugin):
|
||||||
self.shutdown_check = check_callable
|
self.shutdown_check = check_callable
|
||||||
|
|
||||||
def is_configured(self) -> bool:
|
def is_configured(self) -> bool:
|
||||||
# Go through is_authenticated() so the lazy ARL auth fires (post-boot) — otherwise the raw
|
return self._authenticated
|
||||||
# _authenticated flag is still False until something else triggers it, and the hybrid-mode
|
|
||||||
# gate + the green-light status (both read is_configured) silently drop Deezer even though it
|
|
||||||
# downloads fine as a primary source (that path calls is_authenticated). Keeps the three in sync.
|
|
||||||
return self.is_authenticated()
|
|
||||||
|
|
||||||
def is_available(self) -> bool:
|
def is_available(self) -> bool:
|
||||||
return self.is_authenticated()
|
return self._authenticated
|
||||||
|
|
||||||
def is_authenticated(self) -> bool:
|
def is_authenticated(self) -> bool:
|
||||||
if self._pending_arl and not self._authenticated:
|
if self._pending_arl and not self._authenticated:
|
||||||
|
|
|
||||||
|
|
@ -39,14 +39,6 @@ def _positive_float(value: object, default: float = 1.0) -> float:
|
||||||
return f if f > 0 else default
|
return f if f > 0 else default
|
||||||
|
|
||||||
|
|
||||||
def _coerce_float(value: object, default: float = 0.0) -> float:
|
|
||||||
"""Plain float coercion that keeps 0 / negatives (unlike _positive_float) — popularity can be 0."""
|
|
||||||
try:
|
|
||||||
return float(value) # type: ignore[arg-type]
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return default
|
|
||||||
|
|
||||||
|
|
||||||
def _get(row: object, attr: str):
|
def _get(row: object, attr: str):
|
||||||
"""Read a field from a dataclass row or a dict row."""
|
"""Read a field from a dataclass row or a dict row."""
|
||||||
if isinstance(row, dict):
|
if isinstance(row, dict):
|
||||||
|
|
@ -242,46 +234,6 @@ def rank_recommended_artists(
|
||||||
return out[:limit]
|
return out[:limit]
|
||||||
|
|
||||||
|
|
||||||
# ── Adventurousness re-rank (aurral-style popularity penalty) ────────────────────────────────
|
|
||||||
# Both Discover rec rows already EXCLUDE what you own / watch, so "novelty" is baked in; the lever we
|
|
||||||
# were missing is a POPULARITY PENALTY. At higher adventurousness, globally-popular candidates are
|
|
||||||
# pushed down so the obscure / non-obvious picks surface. Pure + reusable across both rec rows.
|
|
||||||
_MAX_POP_PENALTY = 0.7 # at level 1.0 a popularity-100 candidate loses 70% of its score
|
|
||||||
|
|
||||||
|
|
||||||
def apply_adventurousness(
|
|
||||||
items: Sequence[dict],
|
|
||||||
level: object,
|
|
||||||
*,
|
|
||||||
score_key: str = "score",
|
|
||||||
pop_key: str = "popularity",
|
|
||||||
tiebreak_key: str = "seed_count",
|
|
||||||
) -> List[dict]:
|
|
||||||
"""Re-rank ``items`` (dicts with a numeric score + an optional 0–100 popularity) by an
|
|
||||||
adventurousness-scaled popularity penalty. Returns a NEW list, most-adventurous first.
|
|
||||||
|
|
||||||
``level`` is clamped to 0..1. At ``level <= 0`` the input order is returned **unchanged** (a
|
|
||||||
copy), so the feature is fully additive / no-regression. Items missing a popularity are never
|
|
||||||
penalised. Adjusted score = ``score × (1 − level × MAX_POP_PENALTY × popularity/100)``.
|
|
||||||
"""
|
|
||||||
lvl = max(0.0, min(1.0, _coerce_float(level, 0.0)))
|
|
||||||
if lvl <= 0.0:
|
|
||||||
return list(items)
|
|
||||||
|
|
||||||
def _adjusted(it: object) -> float:
|
|
||||||
score = _coerce_float(_get(it, score_key), 0.0)
|
|
||||||
pop = _get(it, pop_key)
|
|
||||||
if pop is None:
|
|
||||||
return score
|
|
||||||
pop_norm = max(0.0, min(1.0, _coerce_float(pop, 0.0) / 100.0))
|
|
||||||
return score * (1.0 - lvl * _MAX_POP_PENALTY * pop_norm)
|
|
||||||
|
|
||||||
return sorted(
|
|
||||||
items,
|
|
||||||
key=lambda it: (-_adjusted(it), -_coerce_float(_get(it, tiebreak_key), 0.0), _norm(_get(it, "name"))),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def aggregate_candidate_tracks(
|
def aggregate_candidate_tracks(
|
||||||
recommended_artists: Sequence[RecommendedArtist],
|
recommended_artists: Sequence[RecommendedArtist],
|
||||||
top_tracks_by_artist: Dict[str, Sequence[dict]],
|
top_tracks_by_artist: Dict[str, Sequence[dict]],
|
||||||
|
|
|
||||||
|
|
@ -10515,32 +10515,6 @@ class MusicDatabase:
|
||||||
logger.error(f"Error checking similar artists freshness: {e}")
|
logger.error(f"Error checking similar artists freshness: {e}")
|
||||||
return False # Default to re-fetching on error
|
return False # Default to re-fetching on error
|
||||||
|
|
||||||
def get_similar_artist_popularities(self, names, profile_id: int = 1):
|
|
||||||
"""Map lowercased artist name -> max stored popularity (0-100) from ``similar_artists`` for
|
|
||||||
the given profile. Lets the Discover routes apply the adventurousness popularity-penalty at
|
|
||||||
request time (the stored listening-recs don't carry popularity inline). Fail-soft -> {}."""
|
|
||||||
out = {}
|
|
||||||
clean = [str(n).strip().lower() for n in (names or []) if str(n or '').strip()]
|
|
||||||
if not clean:
|
|
||||||
return out
|
|
||||||
try:
|
|
||||||
with self._get_connection() as conn:
|
|
||||||
cursor = conn.cursor()
|
|
||||||
placeholders = ','.join('?' for _ in clean)
|
|
||||||
cursor.execute(
|
|
||||||
f"""SELECT LOWER(similar_artist_name) AS n, MAX(popularity) AS pop
|
|
||||||
FROM similar_artists
|
|
||||||
WHERE profile_id = ? AND LOWER(similar_artist_name) IN ({placeholders})
|
|
||||||
GROUP BY LOWER(similar_artist_name)""",
|
|
||||||
[profile_id] + clean,
|
|
||||||
)
|
|
||||||
for row in cursor.fetchall():
|
|
||||||
if row['pop'] is not None:
|
|
||||||
out[row['n']] = row['pop']
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"get_similar_artist_popularities failed: {e}")
|
|
||||||
return out
|
|
||||||
|
|
||||||
def get_top_similar_artists(
|
def get_top_similar_artists(
|
||||||
self,
|
self,
|
||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ from __future__ import annotations
|
||||||
|
|
||||||
from core.discovery.listening_recommendations import (
|
from core.discovery.listening_recommendations import (
|
||||||
aggregate_candidate_tracks,
|
aggregate_candidate_tracks,
|
||||||
apply_adventurousness,
|
|
||||||
build_recency_weighted_seeds,
|
build_recency_weighted_seeds,
|
||||||
choose_mix_fetch_source,
|
choose_mix_fetch_source,
|
||||||
names_match,
|
names_match,
|
||||||
|
|
@ -315,40 +314,3 @@ def test_rank_threading_changes_winner_within_a_seed():
|
||||||
ranked = rank_recommended_artists(seeds, grouped)
|
ranked = rank_recommended_artists(seeds, grouped)
|
||||||
assert [r.name for r in ranked] == ["Close", "Far"]
|
assert [r.name for r in ranked] == ["Close", "Far"]
|
||||||
assert ranked[0].score > ranked[1].score
|
assert ranked[0].score > ranked[1].score
|
||||||
|
|
||||||
|
|
||||||
# ── apply_adventurousness (aurral-style popularity-penalty re-rank) ───────────
|
|
||||||
def test_adventurousness_zero_is_noop_but_copies():
|
|
||||||
items = [{"name": "A", "score": 5.0, "popularity": 90},
|
|
||||||
{"name": "B", "score": 4.0, "popularity": 10}]
|
|
||||||
out = apply_adventurousness(items, 0.0)
|
|
||||||
assert [i["name"] for i in out] == ["A", "B"] # order unchanged
|
|
||||||
assert out == items # same content
|
|
||||||
assert out is not items # but a fresh list (additive)
|
|
||||||
|
|
||||||
|
|
||||||
def test_adventurousness_demotes_the_popular_one():
|
|
||||||
# Same score; at full adventurousness the obscure pick (pop 10) overtakes the giant (pop 95).
|
|
||||||
items = [{"name": "Giant", "score": 5.0, "popularity": 95},
|
|
||||||
{"name": "Obscure", "score": 5.0, "popularity": 10}]
|
|
||||||
assert [i["name"] for i in apply_adventurousness(items, 1.0)] == ["Obscure", "Giant"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_adventurousness_penalty_is_proportional_not_absolute():
|
|
||||||
# A much stronger score still wins despite being more popular — the penalty scales the score.
|
|
||||||
items = [{"name": "StrongPopular", "score": 10.0, "popularity": 80},
|
|
||||||
{"name": "WeakObscure", "score": 1.0, "popularity": 0}]
|
|
||||||
assert apply_adventurousness(items, 0.5)[0]["name"] == "StrongPopular"
|
|
||||||
|
|
||||||
|
|
||||||
def test_adventurousness_missing_popularity_is_unpenalized():
|
|
||||||
items = [{"name": "Popular", "score": 5.0, "popularity": 100},
|
|
||||||
{"name": "NoPop", "score": 5.0}]
|
|
||||||
assert apply_adventurousness(items, 1.0)[0]["name"] == "NoPop"
|
|
||||||
|
|
||||||
|
|
||||||
def test_adventurousness_clamps_level():
|
|
||||||
items = [{"name": "A", "score": 5.0, "popularity": 100},
|
|
||||||
{"name": "B", "score": 5.0, "popularity": 0}]
|
|
||||||
assert [i["name"] for i in apply_adventurousness(items, 5.0)] == ["B", "A"] # >1 clamps to 1
|
|
||||||
assert [i["name"] for i in apply_adventurousness(items, -2.0)] == ["A", "B"] # <0 clamps to 0 (no-op)
|
|
||||||
|
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
"""Regression: Deezer download client `is_configured()` must fire the lazy ARL auth.
|
|
||||||
|
|
||||||
The hybrid-mode source gate + the green-light status both read `is_configured()`. It used to return
|
|
||||||
the raw `_authenticated` flag, which stays False until something calls `is_authenticated()` (the lazy
|
|
||||||
ARL auth). So Deezer downloaded fine as a *primary* source (that path auths) but was silently dropped
|
|
||||||
from a hybrid chain and showed no green light. The fix routes is_configured()/is_available() through
|
|
||||||
is_authenticated() so the three can't drift.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import core.boot_phase as boot
|
|
||||||
from core.deezer_download_client import DeezerDownloadClient
|
|
||||||
|
|
||||||
|
|
||||||
def _bare_client(authenticated=False, pending_arl=None):
|
|
||||||
"""A client without __init__ (no config/network) — just the auth-state seam we're testing."""
|
|
||||||
c = DeezerDownloadClient.__new__(DeezerDownloadClient)
|
|
||||||
c._authenticated = authenticated
|
|
||||||
c._pending_arl = pending_arl
|
|
||||||
return c
|
|
||||||
|
|
||||||
|
|
||||||
def test_is_configured_fires_lazy_arl_auth_post_boot(monkeypatch):
|
|
||||||
monkeypatch.setattr(boot, "is_boot_phase", lambda: False)
|
|
||||||
c = _bare_client(authenticated=False, pending_arl="fake-arl")
|
|
||||||
calls = []
|
|
||||||
|
|
||||||
def fake_auth(arl):
|
|
||||||
calls.append(arl)
|
|
||||||
c._authenticated = True
|
|
||||||
|
|
||||||
c._authenticate = fake_auth
|
|
||||||
|
|
||||||
# The hybrid gate / green light call this — it must trigger the deferred auth, not report False.
|
|
||||||
assert c.is_configured() is True
|
|
||||||
assert c.is_available() is True
|
|
||||||
assert calls == ["fake-arl"]
|
|
||||||
assert c._pending_arl is None
|
|
||||||
|
|
||||||
# Idempotent: once authed, no repeat network auth.
|
|
||||||
assert c.is_configured() is True
|
|
||||||
assert calls == ["fake-arl"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_is_configured_defers_during_boot(monkeypatch):
|
|
||||||
monkeypatch.setattr(boot, "is_boot_phase", lambda: True)
|
|
||||||
c = _bare_client(authenticated=False, pending_arl="fake-arl")
|
|
||||||
|
|
||||||
def boom(arl):
|
|
||||||
raise AssertionError("must not authenticate (network) during boot")
|
|
||||||
|
|
||||||
c._authenticate = boom
|
|
||||||
assert c.is_configured() is False # deferred — stays unauthenticated, but never auths mid-boot
|
|
||||||
|
|
||||||
|
|
||||||
def test_is_configured_true_when_already_authed():
|
|
||||||
c = _bare_client(authenticated=True, pending_arl=None)
|
|
||||||
assert c.is_configured() is True
|
|
||||||
assert c.is_available() is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_is_configured_false_with_no_arl(monkeypatch):
|
|
||||||
monkeypatch.setattr(boot, "is_boot_phase", lambda: False)
|
|
||||||
c = _bare_client(authenticated=False, pending_arl=None) # no ARL configured at all
|
|
||||||
assert c.is_configured() is False
|
|
||||||
|
|
@ -29565,28 +29565,6 @@ def get_discover_similar_artists():
|
||||||
artist_data["because"] = because
|
artist_data["because"] = because
|
||||||
result_artists.append(artist_data)
|
result_artists.append(artist_data)
|
||||||
|
|
||||||
# Adventurousness re-rank (aurral-style): push globally-popular picks down per the user's dial.
|
|
||||||
# Derive a score from the SQL signals (occurrence primary, similarity a minor tiebreak); at
|
|
||||||
# level 0 apply_adventurousness returns the list unchanged, so the featured-rotation order is
|
|
||||||
# fully preserved. Fail-soft. The frontend shows the top slice, so the obscure ones surface.
|
|
||||||
try:
|
|
||||||
_adv_level = float(config_manager.get('discover.adventurousness', 0.3) or 0)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
_adv_level = 0.0
|
|
||||||
if _adv_level > 0 and result_artists:
|
|
||||||
try:
|
|
||||||
for a in result_artists:
|
|
||||||
_oc = float(a.get('occurrence_count') or 0)
|
|
||||||
_rank = min(float(a.get('similarity_rank') or 10), 10.0)
|
|
||||||
a['_adv_score'] = _oc + (10.0 - _rank) * 0.1
|
|
||||||
from core.discovery.listening_recommendations import apply_adventurousness
|
|
||||||
result_artists = apply_adventurousness(
|
|
||||||
result_artists, _adv_level, score_key='_adv_score', tiebreak_key='occurrence_count')
|
|
||||||
for a in result_artists:
|
|
||||||
a.pop('_adv_score', None)
|
|
||||||
except Exception as _adv_err:
|
|
||||||
logger.debug(f"similar-artists adventurousness re-rank skipped: {_adv_err}")
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[Similar Artists] {len(similar_artists)} from DB, {len(result_artists)} valid for "
|
f"[Similar Artists] {len(similar_artists)} from DB, {len(result_artists)} valid for "
|
||||||
f"{active_source} after excluding {active_server} library artists"
|
f"{active_source} after excluding {active_server} library artists"
|
||||||
|
|
@ -29604,31 +29582,6 @@ def get_discover_similar_artists():
|
||||||
return jsonify({"success": False, "error": str(e)}), 500
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/discover/adventurousness', methods=['GET', 'POST'])
|
|
||||||
def discover_adventurousness():
|
|
||||||
"""Get/set the global Discover adventurousness dial (0..1). Shares the config key
|
|
||||||
``discover.adventurousness`` with the Settings -> Discovery slider, so the two controls stay in
|
|
||||||
sync (change one, the other reflects it on next load). Read-only-safe; clamps to [0, 1]."""
|
|
||||||
try:
|
|
||||||
if request.method == 'POST':
|
|
||||||
data = request.get_json(silent=True) or {}
|
|
||||||
try:
|
|
||||||
v = float(data.get('value'))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return jsonify({"success": False, "error": "value must be a number"}), 400
|
|
||||||
v = max(0.0, min(1.0, v))
|
|
||||||
config_manager.set('discover.adventurousness', v)
|
|
||||||
return jsonify({"success": True, "value": v})
|
|
||||||
try:
|
|
||||||
v = float(config_manager.get('discover.adventurousness', 0.3) or 0)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
v = 0.3
|
|
||||||
return jsonify({"success": True, "value": max(0.0, min(1.0, v))})
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"adventurousness endpoint error: {e}")
|
|
||||||
return jsonify({"success": False, "error": str(e)}), 500
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/discover/listening-recommendations', methods=['GET'])
|
@app.route('/api/discover/listening-recommendations', methods=['GET'])
|
||||||
def get_discover_listening_recommendations():
|
def get_discover_listening_recommendations():
|
||||||
"""#913: artists you'd love based on what you actually LISTEN to (play-weighted).
|
"""#913: artists you'd love based on what you actually LISTEN to (play-weighted).
|
||||||
|
|
@ -29651,24 +29604,6 @@ def get_discover_listening_recommendations():
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
stored = []
|
stored = []
|
||||||
|
|
||||||
# Adventurousness re-rank (aurral-style): enrich popularity at request time (the stored recs
|
|
||||||
# don't carry it inline, so this works on existing data), then push globally-popular picks
|
|
||||||
# down per the user's dial. level 0 -> unchanged. Fail-soft: any hiccup leaves the order.
|
|
||||||
try:
|
|
||||||
level = float(config_manager.get('discover.adventurousness', 0.3) or 0)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
level = 0.0
|
|
||||||
if level > 0 and stored:
|
|
||||||
try:
|
|
||||||
pops = database.get_similar_artist_popularities([a.get('name') for a in stored])
|
|
||||||
for a in stored:
|
|
||||||
if a.get('popularity') is None:
|
|
||||||
a['popularity'] = pops.get((a.get('name') or '').strip().lower())
|
|
||||||
from core.discovery.listening_recommendations import apply_adventurousness
|
|
||||||
stored = apply_adventurousness(stored, level)
|
|
||||||
except Exception as _adv_err:
|
|
||||||
logger.debug(f"adventurousness re-rank skipped: {_adv_err}")
|
|
||||||
|
|
||||||
result_artists = []
|
result_artists = []
|
||||||
for a in stored:
|
for a in stored:
|
||||||
name = a.get('name')
|
name = a.get('name')
|
||||||
|
|
|
||||||
114
webui/index.html
114
webui/index.html
|
|
@ -3146,35 +3146,6 @@
|
||||||
<div class="artist-map-search-results" id="artist-map-search-results"></div>
|
<div class="artist-map-search-results" id="artist-map-search-results"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Discovery Adventurousness — living wave dial. Drag the orb; the line waves
|
|
||||||
calmer/greener at left (safe) and wilder/redder at right (obscure). Syncs with
|
|
||||||
Settings → Discovery. -->
|
|
||||||
<div class="adv-wave" id="adv-wave"
|
|
||||||
title="Pushes globally-popular artists down so more obscure picks surface in your recommendations">
|
|
||||||
<div class="adv-wave-head">
|
|
||||||
<span class="adv-wave-label">Adventurousness</span>
|
|
||||||
<span class="adv-wave-state" id="adv-wave-state">Balanced</span>
|
|
||||||
</div>
|
|
||||||
<div class="adv-wave-track" id="adv-wave-track">
|
|
||||||
<div class="adv-wave-aura" id="adv-wave-aura"></div>
|
|
||||||
<svg class="adv-wave-svg" id="adv-wave-svg" viewBox="0 0 1000 80" preserveAspectRatio="none" aria-hidden="true">
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="adv-wave-fill" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop id="adv-wave-fill-top" offset="0" stop-color="#1DB954" stop-opacity="0.32"/>
|
|
||||||
<stop offset="1" stop-color="#1DB954" stop-opacity="0"/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<path id="adv-wave-area" fill="url(#adv-wave-fill)" stroke="none"></path>
|
|
||||||
<path id="adv-wave-path" fill="none" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"></path>
|
|
||||||
</svg>
|
|
||||||
<div class="adv-wave-orb" id="adv-wave-orb"></div>
|
|
||||||
</div>
|
|
||||||
<div class="adv-wave-ends">
|
|
||||||
<span>Safe — artists you already like</span>
|
|
||||||
<span>Adventurous — deep cuts</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Recommended For You Section (similar-artists graph) -->
|
<!-- Recommended For You Section (similar-artists graph) -->
|
||||||
<!-- #913: listening-driven recommendations (play-weighted, consensus-ranked) -->
|
<!-- #913: listening-driven recommendations (play-weighted, consensus-ranked) -->
|
||||||
<div class="discover-section" id="listening-recs-section" style="display: none;">
|
<div class="discover-section" id="listening-recs-section" style="display: none;">
|
||||||
|
|
@ -3184,24 +3155,12 @@
|
||||||
<p class="discover-section-subtitle">Artists you'd love — ranked from who you actually play the most</p>
|
<p class="discover-section-subtitle">Artists you'd love — ranked from who you actually play the most</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="discover-grid" id="listening-recs-carousel">
|
<div class="discover-carousel" id="listening-recs-carousel">
|
||||||
<!-- Populated by JS -->
|
<!-- Populated by JS -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- #913: Listening Mix — a playable track mix from those recommended artists -->
|
<!-- #913: Listening Mix — a playable track mix from those recommended artists -->
|
||||||
<!-- Your Mixes — Spotify-style shelf: each mix is ONE card (mosaic cover),
|
|
||||||
click opens the track list + actions in a modal (#discover redesign). -->
|
|
||||||
<div class="discover-section" id="your-mixes-section" style="display: none;">
|
|
||||||
<div class="discover-section-header">
|
|
||||||
<div>
|
|
||||||
<h2 class="discover-section-title">Your Mixes</h2>
|
|
||||||
<p class="discover-section-subtitle">Fresh playlists built from your listening — open one to see the tracks</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="discover-mixes-grid" id="your-mixes-grid"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="discover-section" style="display: none;">
|
<div class="discover-section" style="display: none;">
|
||||||
<div class="discover-section-header">
|
<div class="discover-section-header">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -3248,7 +3207,7 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="discover-grid" id="recommended-artists-carousel">
|
<div class="discover-carousel" id="recommended-artists-carousel">
|
||||||
<!-- Populated by JS -->
|
<!-- Populated by JS -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -3312,7 +3271,7 @@
|
||||||
<option value="recent">Date Added</option>
|
<option value="recent">Date Added</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="discover-grid" id="your-albums-grid">
|
<div class="spotify-library-grid" id="your-albums-grid">
|
||||||
<div class="discover-loading">
|
<div class="discover-loading">
|
||||||
<div class="loading-spinner"></div>
|
<div class="loading-spinner"></div>
|
||||||
<p>Loading your albums...</p>
|
<p>Loading your albums...</p>
|
||||||
|
|
@ -3327,7 +3286,7 @@
|
||||||
<h2 class="discover-section-title">Recent Releases</h2>
|
<h2 class="discover-section-title">Recent Releases</h2>
|
||||||
<p class="discover-section-subtitle">New music from artists you follow</p>
|
<p class="discover-section-subtitle">New music from artists you follow</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="discover-grid" id="recent-releases-carousel">
|
<div class="discover-carousel" id="recent-releases-carousel">
|
||||||
<!-- Content will be populated dynamically -->
|
<!-- Content will be populated dynamically -->
|
||||||
<div class="discover-loading">
|
<div class="discover-loading">
|
||||||
<div class="loading-spinner"></div>
|
<div class="loading-spinner"></div>
|
||||||
|
|
@ -3342,7 +3301,7 @@
|
||||||
<h2 class="discover-section-title" id="seasonal-albums-title">Seasonal</h2>
|
<h2 class="discover-section-title" id="seasonal-albums-title">Seasonal</h2>
|
||||||
<p class="discover-section-subtitle" id="seasonal-albums-subtitle">Seasonal music</p>
|
<p class="discover-section-subtitle" id="seasonal-albums-subtitle">Seasonal music</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="discover-grid" id="seasonal-albums-carousel">
|
<div class="discover-carousel" id="seasonal-albums-carousel">
|
||||||
<!-- Content will be populated dynamically -->
|
<!-- Content will be populated dynamically -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -3810,24 +3769,43 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Year Mixes (decade mix cards — same setup as Your Mixes) -->
|
<!-- Time Machine (Tabbed by Decade) -->
|
||||||
<div class="discover-section" id="year-mixes-section">
|
<div class="discover-section">
|
||||||
<div class="discover-section-header">
|
<div class="discover-section-header">
|
||||||
<div>
|
<h2 class="discover-section-title">⏰ Time Machine</h2>
|
||||||
<h2 class="discover-section-title">Year Mixes</h2>
|
<p class="discover-section-subtitle">Explore music from different decades</p>
|
||||||
<p class="discover-section-subtitle">Jump into the sound of a decade — open one to see the tracks</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Decade Tabs (hidden — replaced by the decade mix-card grid below) -->
|
<!-- Decade Tabs (will be populated dynamically) -->
|
||||||
<div class="decade-tabs" id="decade-tabs" style="display:none"></div>
|
<div class="decade-tabs" id="decade-tabs">
|
||||||
|
<div class="discover-loading">
|
||||||
|
<div class="loading-spinner"></div>
|
||||||
|
<p>Loading decades...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Decade Tab Contents (will be populated dynamically) -->
|
<!-- Decade Tab Contents (will be populated dynamically) -->
|
||||||
<div id="decade-tab-contents"></div>
|
<div id="decade-tab-contents"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Browse by Genre removed (#discover redesign): it was empty (no data from
|
<!-- Browse by Genre (Tabbed by Genre) -->
|
||||||
/api/discover/genres/available) and redundant with the Genre Explorer pills. -->
|
<div class="discover-section">
|
||||||
|
<div class="discover-section-header">
|
||||||
|
<h2 class="discover-section-title">🎵 Browse by Genre</h2>
|
||||||
|
<p class="discover-section-subtitle">Discover music by your favorite genres</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Genre Tabs (will be populated dynamically) -->
|
||||||
|
<div class="genre-tabs" id="genre-tabs">
|
||||||
|
<div class="discover-loading">
|
||||||
|
<div class="loading-spinner"></div>
|
||||||
|
<p>Loading genres...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Genre Tab Contents (will be populated dynamically) -->
|
||||||
|
<div id="genre-tab-contents"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -6230,30 +6208,6 @@
|
||||||
</div>
|
</div>
|
||||||
</div><!-- end Listening Stats body -->
|
</div><!-- end Listening Stats body -->
|
||||||
|
|
||||||
<!-- Discovery (collapsible tile) -->
|
|
||||||
<div class="settings-section-header collapsed" data-stg="library" onclick="this.classList.toggle('collapsed'); const b=this.nextElementSibling; b.classList.toggle('collapsed'); b.style.display=b.classList.contains('collapsed')?'none':''">
|
|
||||||
<span class="settings-section-arrow">▼</span>
|
|
||||||
<h3>Discovery</h3>
|
|
||||||
<span class="settings-section-hint">How adventurous your recommendations are</span>
|
|
||||||
</div>
|
|
||||||
<div class="settings-section-body collapsed" data-stg="library" style="display:none">
|
|
||||||
<div class="settings-group" data-stg="library">
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Adventurousness</label>
|
|
||||||
<div style="display:flex; align-items:center; gap:12px; margin-top:8px">
|
|
||||||
<span style="font-size:12px; color:#999">Safe</span>
|
|
||||||
<input type="range" id="discover-adventurousness" min="0" max="1" step="0.05" value="0.3" style="flex:1"
|
|
||||||
oninput="var v=document.getElementById('discover-adventurousness-val'); if(v) v.textContent=parseFloat(this.value).toFixed(2)">
|
|
||||||
<span style="font-size:12px; color:#999">Adventurous</span>
|
|
||||||
<span id="discover-adventurousness-val" style="min-width:36px; text-align:right; font-weight:700; color:var(--accent)">0.30</span>
|
|
||||||
</div>
|
|
||||||
<div class="help-text setting-help-body" style="margin-top:10px">
|
|
||||||
Higher pushes globally-popular artists down so more obscure, non-obvious picks surface; lower keeps recommendations safe and close to what you already know. Affects <strong>Based On Your Listening</strong>.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div><!-- end Discovery body -->
|
|
||||||
|
|
||||||
<!-- Security & Access (collapsible tile) -->
|
<!-- Security & Access (collapsible tile) -->
|
||||||
<div class="settings-section-header collapsed" data-stg="advanced" onclick="this.classList.toggle('collapsed'); const b=this.nextElementSibling; b.classList.toggle('collapsed'); b.style.display=b.classList.contains('collapsed')?'none':''">
|
<div class="settings-section-header collapsed" data-stg="advanced" onclick="this.classList.toggle('collapsed'); const b=this.nextElementSibling; b.classList.toggle('collapsed'); b.style.display=b.classList.contains('collapsed')?'none':''">
|
||||||
<span class="settings-section-arrow">▼</span>
|
<span class="settings-section-arrow">▼</span>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -724,9 +724,7 @@ const HYBRID_SOURCE_PROBE = {
|
||||||
tidal: () => _ssTestConn('tidal'),
|
tidal: () => _ssTestConn('tidal'),
|
||||||
qobuz: () => _ssJson('/api/qobuz/auth/status').then(j => j.authenticated === true),
|
qobuz: () => _ssJson('/api/qobuz/auth/status').then(j => j.authenticated === true),
|
||||||
hifi: () => _ssJson('/api/hifi/status').then(j => j.available === true),
|
hifi: () => _ssJson('/api/hifi/status').then(j => j.available === true),
|
||||||
// POST (the endpoint is POST-only) with an empty body so it tests the SAVED ARL; a GET here
|
deezer_dl: () => _ssJson('/api/deezer-download/test').then(j => j.success === true),
|
||||||
// 405s and the probe throws -> the dot goes red even though Deezer downloads fine.
|
|
||||||
deezer_dl: () => _ssJson('/api/deezer-download/test', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }).then(j => j.success === true),
|
|
||||||
amazon: () => _ssJson('/api/amazon/test-connection').then(j => j.connected === true),
|
amazon: () => _ssJson('/api/amazon/test-connection').then(j => j.connected === true),
|
||||||
lidarr: () => _ssTestConn('lidarr'),
|
lidarr: () => _ssTestConn('lidarr'),
|
||||||
soundcloud: () => _ssJson('/api/soundcloud/status').then(j => j.available === true && j.reachable === true),
|
soundcloud: () => _ssJson('/api/soundcloud/status').then(j => j.available === true && j.reachable === true),
|
||||||
|
|
@ -1482,13 +1480,6 @@ async function loadSettingsData() {
|
||||||
// Populate Listening Stats settings
|
// Populate Listening Stats settings
|
||||||
document.getElementById('listening-stats-enabled').checked = settings.listening_stats?.enabled === true;
|
document.getElementById('listening-stats-enabled').checked = settings.listening_stats?.enabled === true;
|
||||||
document.getElementById('listening-stats-interval').value = settings.listening_stats?.poll_interval || 30;
|
document.getElementById('listening-stats-interval').value = settings.listening_stats?.poll_interval || 30;
|
||||||
const _advEl = document.getElementById('discover-adventurousness');
|
|
||||||
if (_advEl) {
|
|
||||||
const _adv = settings.discover?.adventurousness;
|
|
||||||
_advEl.value = (typeof _adv === 'number') ? _adv : 0.3;
|
|
||||||
const _advVal = document.getElementById('discover-adventurousness-val');
|
|
||||||
if (_advVal) _advVal.textContent = parseFloat(_advEl.value).toFixed(2);
|
|
||||||
}
|
|
||||||
document.getElementById('lossy-copy-options').style.display =
|
document.getElementById('lossy-copy-options').style.display =
|
||||||
settings.lossy_copy?.enabled ? 'block' : 'none';
|
settings.lossy_copy?.enabled ? 'block' : 'none';
|
||||||
|
|
||||||
|
|
@ -3504,13 +3495,6 @@ async function saveSettings(quiet = false) {
|
||||||
enabled: document.getElementById('listening-stats-enabled').checked,
|
enabled: document.getElementById('listening-stats-enabled').checked,
|
||||||
poll_interval: parseInt(document.getElementById('listening-stats-interval').value) || 30,
|
poll_interval: parseInt(document.getElementById('listening-stats-interval').value) || 30,
|
||||||
},
|
},
|
||||||
discover: {
|
|
||||||
// Adventurousness dial (0 safe .. 1 obscure) — drives the Discover popularity penalty.
|
|
||||||
// Use the slider's value directly (0 is valid; don't `|| 0.3` it away).
|
|
||||||
adventurousness: document.getElementById('discover-adventurousness')
|
|
||||||
? parseFloat(document.getElementById('discover-adventurousness').value)
|
|
||||||
: 0.3,
|
|
||||||
},
|
|
||||||
m3u_export: {
|
m3u_export: {
|
||||||
enabled: document.getElementById('m3u-export-enabled').checked,
|
enabled: document.getElementById('m3u-export-enabled').checked,
|
||||||
entry_base_path: document.getElementById('m3u-entry-base-path').value || ''
|
entry_base_path: document.getElementById('m3u-entry-base-path').value || ''
|
||||||
|
|
|
||||||
|
|
@ -35006,236 +35006,6 @@ div.artist-hero-badge {
|
||||||
}
|
}
|
||||||
.ya-card-name:hover { color: var(--accent); }
|
.ya-card-name:hover { color: var(--accent); }
|
||||||
|
|
||||||
/* ── Your Mixes (Spotify-style playlist cards, #discover redesign) ───────────
|
|
||||||
Each mix is ONE card: a 2x2 mosaic cover from its top tracks + name below, with
|
|
||||||
a hover play button. Click opens the track list in a modal. Replaces the old
|
|
||||||
full-width compact-track tables. */
|
|
||||||
.discover-mixes-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
|
|
||||||
gap: 18px;
|
|
||||||
}
|
|
||||||
.discover-mix-card {
|
|
||||||
background: rgba(255,255,255,0.03);
|
|
||||||
border: 1px solid rgba(255,255,255,0.06);
|
|
||||||
border-radius: 14px;
|
|
||||||
padding: 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: transform 0.3s cubic-bezier(0.4,0,0.2,1), border-color 0.3s, box-shadow 0.3s, background 0.3s;
|
|
||||||
}
|
|
||||||
.discover-mix-card:hover {
|
|
||||||
transform: translateY(-5px);
|
|
||||||
background: rgba(255,255,255,0.06);
|
|
||||||
border-color: rgba(var(--accent-rgb),0.25);
|
|
||||||
box-shadow: 0 12px 40px rgba(0,0,0,0.5), 0 0 24px rgba(var(--accent-rgb),0.12);
|
|
||||||
}
|
|
||||||
.mix-card-cover {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
aspect-ratio: 1;
|
|
||||||
border-radius: 10px;
|
|
||||||
overflow: hidden;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
grid-template-rows: 1fr 1fr;
|
|
||||||
box-shadow: 0 6px 20px rgba(0,0,0,0.4);
|
|
||||||
}
|
|
||||||
.mix-card-tile {
|
|
||||||
background-size: cover;
|
|
||||||
background-position: center;
|
|
||||||
background-color: #1a1a1a;
|
|
||||||
}
|
|
||||||
.mix-card-play {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 10px; right: 10px;
|
|
||||||
width: 44px; height: 44px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--accent);
|
|
||||||
color: #000;
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
font-size: 15px; padding-left: 2px;
|
|
||||||
box-shadow: 0 6px 16px rgba(0,0,0,0.45);
|
|
||||||
opacity: 0; transform: translateY(8px);
|
|
||||||
transition: opacity 0.25s, transform 0.25s;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
.discover-mix-card:hover .mix-card-play {
|
|
||||||
opacity: 1; transform: translateY(0);
|
|
||||||
}
|
|
||||||
.mix-card-name {
|
|
||||||
font-size: 14px; font-weight: 700; color: #fff;
|
|
||||||
margin-top: 12px;
|
|
||||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
.mix-card-meta {
|
|
||||||
font-size: 12px; color: rgba(255,255,255,0.5);
|
|
||||||
margin-top: 3px;
|
|
||||||
}
|
|
||||||
/* Solid/gradient cover for mixes with no per-track art (e.g. decades). */
|
|
||||||
.mix-card-cover--solid {
|
|
||||||
background: linear-gradient(135deg, rgba(var(--accent-rgb),0.42), rgba(var(--accent-rgb),0.10));
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
}
|
|
||||||
.mix-card-decade {
|
|
||||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
|
||||||
gap: 4px; color: #fff; text-shadow: 0 2px 8px rgba(0,0,0,0.4);
|
|
||||||
}
|
|
||||||
.mix-card-decade-icon { font-size: 34px; line-height: 1; }
|
|
||||||
.mix-card-decade-label { font-size: 22px; font-weight: 800; letter-spacing: 0.5px; }
|
|
||||||
/* The mix modal — opened from a mix card; holds the track list + per-mix actions. */
|
|
||||||
.mix-modal {
|
|
||||||
background: #14141c; border-radius: 18px;
|
|
||||||
width: 760px; max-width: 94vw; max-height: 86vh;
|
|
||||||
display: flex; flex-direction: column;
|
|
||||||
box-shadow: 0 24px 70px rgba(0,0,0,0.6);
|
|
||||||
border: 1px solid rgba(255,255,255,0.08);
|
|
||||||
}
|
|
||||||
.mix-modal-header {
|
|
||||||
display: flex; justify-content: space-between; align-items: flex-start;
|
|
||||||
padding: 22px 24px; gap: 16px;
|
|
||||||
border-bottom: 1px solid rgba(255,255,255,0.06);
|
|
||||||
}
|
|
||||||
.mix-modal-subtitle { font-size: 12px; color: #999; }
|
|
||||||
.mix-modal-title { font-size: 24px; font-weight: 700; color: #fff; margin: 4px 0; }
|
|
||||||
.mix-modal-meta { font-size: 12px; color: rgba(255,255,255,0.5); }
|
|
||||||
.mix-modal-actions { display: flex; gap: 8px; align-items: center; flex-shrink: 0; }
|
|
||||||
.mix-modal-close {
|
|
||||||
background: none; border: none; color: rgba(255,255,255,0.5);
|
|
||||||
font-size: 16px; cursor: pointer; padding: 6px; line-height: 1;
|
|
||||||
}
|
|
||||||
.mix-modal-close:hover { color: #fff; }
|
|
||||||
.mix-modal-body { overflow-y: auto; padding: 16px 24px 24px; }
|
|
||||||
|
|
||||||
/* ── Unified Discover card grid (#discover redesign) ─────────────────────────
|
|
||||||
One responsive wrapping grid (fills the section width, wraps to rows — no
|
|
||||||
horizontal scroll) holding the same .ya-card the Your Artists section uses, so
|
|
||||||
every album/artist section reads as one design. */
|
|
||||||
.discover-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
.discover-grid .ya-card { width: 100%; }
|
|
||||||
/* Albums are square, so don't crop the cover with the artist card's 0.85 portrait ratio. */
|
|
||||||
.discover-grid .discover-album-card.ya-card { aspect-ratio: 1; }
|
|
||||||
/* Genre cards: no cover art — square gradient tile with the genre emoji centered. */
|
|
||||||
.discover-grid .discover-genre-card.ya-card { aspect-ratio: 1; }
|
|
||||||
.discover-genre-card .genre-card-art {
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
background: linear-gradient(135deg, rgba(var(--accent-rgb),0.38), rgba(var(--accent-rgb),0.07));
|
|
||||||
}
|
|
||||||
.discover-genre-card .genre-icon-large { font-size: 46px; line-height: 1; }
|
|
||||||
/* Secondary line under the title (album → artist). */
|
|
||||||
.ya-card-sub {
|
|
||||||
font-size: 11px; font-weight: 500;
|
|
||||||
color: rgba(255,255,255,0.72);
|
|
||||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
||||||
text-shadow: 0 1px 3px rgba(0,0,0,0.6);
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
/* Owned / missing indicator on album cards (corner badge). */
|
|
||||||
.discover-album-badge {
|
|
||||||
width: 20px; height: 20px; border-radius: 50%;
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
font-size: 11px; font-weight: 700;
|
|
||||||
box-shadow: 0 2px 6px rgba(0,0,0,0.45);
|
|
||||||
}
|
|
||||||
.discover-album-badge.owned { background: #1DB954; color: #000; }
|
|
||||||
.discover-album-badge.missing {
|
|
||||||
background: rgba(0,0,0,0.55); color: rgba(255,255,255,0.85);
|
|
||||||
border: 1px solid rgba(255,255,255,0.2);
|
|
||||||
}
|
|
||||||
/* Show all / Show less toggle under a clamped discover-grid. */
|
|
||||||
.discover-show-all {
|
|
||||||
margin: 14px auto 0;
|
|
||||||
display: block;
|
|
||||||
background: rgba(255,255,255,0.06);
|
|
||||||
border: 1px solid rgba(255,255,255,0.12);
|
|
||||||
color: rgba(255,255,255,0.85);
|
|
||||||
font-size: 13px; font-weight: 600;
|
|
||||||
padding: 8px 20px; border-radius: 20px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.2s, border-color 0.2s, color 0.2s;
|
|
||||||
}
|
|
||||||
.discover-show-all:hover {
|
|
||||||
background: rgba(var(--accent-rgb),0.15);
|
|
||||||
border-color: rgba(var(--accent-rgb),0.4);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Adventurousness — living wave dial (Discover page) ──────────────────────
|
|
||||||
A draggable orb rides an animated line that waves calmer + greener at the left (safe)
|
|
||||||
and wilder + redder at the right (obscure). The path + colours are drawn each frame in
|
|
||||||
JS (rAF). Syncs with Settings → Discovery. */
|
|
||||||
.adv-wave {
|
|
||||||
position: relative;
|
|
||||||
margin: 0 0 28px;
|
|
||||||
padding: 18px 26px 16px;
|
|
||||||
border-radius: 18px;
|
|
||||||
background: linear-gradient(135deg, rgba(255,255,255,0.04), rgba(255,255,255,0.012));
|
|
||||||
border: 1px solid rgba(255,255,255,0.07);
|
|
||||||
box-shadow: 0 10px 34px rgba(0,0,0,0.30);
|
|
||||||
overflow: hidden; /* contain the colour aura wash */
|
|
||||||
}
|
|
||||||
.adv-wave-head { display: flex; flex-direction: column; gap: 1px; position: relative; z-index: 2; }
|
|
||||||
.adv-wave-label { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 2.5px; color: rgba(255,255,255,0.38); }
|
|
||||||
.adv-wave-state { font-size: 21px; font-weight: 800; letter-spacing: -0.3px; line-height: 1.15; transition: color 0.25s; }
|
|
||||||
.adv-wave-track {
|
|
||||||
position: relative; height: 104px; margin: 6px 0 2px;
|
|
||||||
cursor: grab; touch-action: none;
|
|
||||||
}
|
|
||||||
.adv-wave-track:active { cursor: grabbing; }
|
|
||||||
.adv-wave-aura {
|
|
||||||
position: absolute; top: 50%; left: 30%;
|
|
||||||
width: 360px; height: 230px; transform: translate(-50%, -50%);
|
|
||||||
pointer-events: none; filter: blur(14px); z-index: 0;
|
|
||||||
will-change: left, background;
|
|
||||||
}
|
|
||||||
.adv-wave-svg { width: 100%; height: 100%; display: block; overflow: visible; position: relative; z-index: 1; }
|
|
||||||
.adv-wave-orb {
|
|
||||||
position: absolute; top: 50%; left: 30%; z-index: 2;
|
|
||||||
width: 22px; height: 22px; border-radius: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
background: #1DB954; color: #1DB954;
|
|
||||||
box-shadow: 0 0 9px 0 #1DB954, inset 0 0 0 2px rgba(255,255,255,0.5);
|
|
||||||
pointer-events: none; /* the track captures the pointer; the orb is purely visual */
|
|
||||||
will-change: left, top;
|
|
||||||
}
|
|
||||||
.adv-wave-orb::after {
|
|
||||||
content: ''; position: absolute; inset: -5px; border-radius: 50%;
|
|
||||||
border: 1.5px solid currentColor; opacity: 0.4;
|
|
||||||
animation: adv-orb-pulse 2.8s ease-out infinite;
|
|
||||||
}
|
|
||||||
@keyframes adv-orb-pulse {
|
|
||||||
0% { transform: scale(0.75); opacity: 0.4; }
|
|
||||||
100% { transform: scale(1.5); opacity: 0; }
|
|
||||||
}
|
|
||||||
.adv-wave-ends {
|
|
||||||
display: flex; justify-content: space-between;
|
|
||||||
font-size: 11px; font-weight: 500; color: rgba(255,255,255,0.35);
|
|
||||||
position: relative; z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Watchlist toggle on recommended-artist .ya-cards — corner pill, appears on hover,
|
|
||||||
stays lit when already watching. Keeps the .recommended-card-watchlist-btn hook. */
|
|
||||||
.recommended-artist-card .recommended-card-watchlist-btn {
|
|
||||||
position: absolute; top: 8px; right: 8px; z-index: 3;
|
|
||||||
background: rgba(0,0,0,0.72); color: #fff;
|
|
||||||
border: 1px solid rgba(255,255,255,0.25);
|
|
||||||
border-radius: 14px; padding: 4px 10px;
|
|
||||||
font-size: 10px; font-weight: 600; cursor: pointer;
|
|
||||||
opacity: 0; transform: translateY(-4px);
|
|
||||||
transition: opacity 0.2s, transform 0.2s, background 0.2s, border-color 0.2s;
|
|
||||||
}
|
|
||||||
.recommended-artist-card:hover .recommended-card-watchlist-btn { opacity: 1; transform: none; }
|
|
||||||
.recommended-artist-card .recommended-card-watchlist-btn:hover {
|
|
||||||
background: var(--accent); color: #000; border-color: transparent;
|
|
||||||
}
|
|
||||||
.recommended-artist-card .recommended-card-watchlist-btn.watching {
|
|
||||||
opacity: 1; transform: none;
|
|
||||||
background: var(--accent); color: #000; border-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Artist Info Modal ── */
|
/* ── Artist Info Modal ── */
|
||||||
.ya-info-modal {
|
.ya-info-modal {
|
||||||
background: #141420; border-radius: 18px;
|
background: #141420; border-radius: 18px;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue