discovery: fancy Adventurousness dial on the Discover page (synced with Settings)
A prominent control above the rec rows: gradient slider (Safe green -> accent -> hot orange), glowing
grab-thumb, a compass icon that swaps with the level (lifebuoy/compass/dice/crystal-ball) and a live
state word ("Playing it safe" -> "Deep cuts only"). Dragging updates the label live; releasing saves
and immediately re-fetches both rec rows so the effect is instant.
Shares the config key discover.adventurousness with the Settings -> Discovery slider via a new
GET/POST /api/discover/adventurousness endpoint, so the two controls stay in sync (change one, the
other reflects it on next load) — one source of truth, no divergence.
64 script-integrity tests green; route ruff-clean + compiles.
This commit is contained in:
parent
c47b36cd6f
commit
71a48dd344
4 changed files with 142 additions and 0 deletions
|
|
@ -29604,6 +29604,31 @@ def get_discover_similar_artists():
|
|||
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'])
|
||||
def get_discover_listening_recommendations():
|
||||
"""#913: artists you'd love based on what you actually LISTEN to (play-weighted).
|
||||
|
|
|
|||
|
|
@ -3146,6 +3146,25 @@
|
|||
<div class="artist-map-search-results" id="artist-map-search-results"></div>
|
||||
</div>
|
||||
|
||||
<!-- Discovery Adventurousness dial — fancy, syncs with Settings → Discovery -->
|
||||
<div class="adv-dial" id="adv-dial">
|
||||
<div class="adv-dial-head">
|
||||
<div class="adv-dial-icon" id="adv-dial-icon">🧭</div>
|
||||
<div>
|
||||
<div class="adv-dial-title">Adventurousness</div>
|
||||
<div class="adv-dial-state" id="adv-dial-state">Balanced</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="adv-dial-slider-wrap">
|
||||
<span class="adv-dial-end">Safe</span>
|
||||
<input type="range" class="adv-dial-slider" id="adv-dial-slider" min="0" max="1" step="0.05" value="0.3"
|
||||
oninput="onAdvDialInput(this.value)" onchange="onAdvDialCommit(this.value)"
|
||||
aria-label="Discovery adventurousness">
|
||||
<span class="adv-dial-end">Adventurous</span>
|
||||
</div>
|
||||
<div class="adv-dial-hint">Pushes popular artists down so obscure picks surface in your recommendations</div>
|
||||
</div>
|
||||
|
||||
<!-- Recommended For You Section (similar-artists graph) -->
|
||||
<!-- #913: listening-driven recommendations (play-weighted, consensus-ranked) -->
|
||||
<div class="discover-section" id="listening-recs-section" style="display: none;">
|
||||
|
|
|
|||
|
|
@ -22,12 +22,57 @@ let personalizedDiscoveryShuffle = [];
|
|||
let personalizedListeningMix = []; // #913: the "Your Listening Mix" track playlist
|
||||
let buildPlaylistSelectedArtists = [];
|
||||
|
||||
// ── Adventurousness dial (Discover page) — fancy control, syncs with Settings → Discovery ──
|
||||
// Same config key (discover.adventurousness) as the Settings slider, so the two stay in sync.
|
||||
function _advState(v) {
|
||||
if (v < 0.12) return { word: 'Playing it safe', icon: '🛟' };
|
||||
if (v < 0.40) return { word: 'Balanced', icon: '🧭' };
|
||||
if (v < 0.70) return { word: 'Adventurous', icon: '🎲' };
|
||||
return { word: 'Deep cuts only', icon: '🔮' };
|
||||
}
|
||||
function onAdvDialInput(value) {
|
||||
const s = _advState(parseFloat(value));
|
||||
const stateEl = document.getElementById('adv-dial-state');
|
||||
if (stateEl) stateEl.textContent = s.word;
|
||||
const iconEl = document.getElementById('adv-dial-icon');
|
||||
if (iconEl) iconEl.textContent = s.icon;
|
||||
}
|
||||
let _advCommitTimer = null;
|
||||
function onAdvDialCommit(value) {
|
||||
const v = parseFloat(value);
|
||||
clearTimeout(_advCommitTimer);
|
||||
_advCommitTimer = setTimeout(async () => {
|
||||
try {
|
||||
await fetch('/api/discover/adventurousness', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ value: v }),
|
||||
});
|
||||
} catch (e) { console.debug('adventurousness save failed', e); }
|
||||
// Re-fetch the two rec rows so the dial is felt immediately (the routes re-rank live).
|
||||
if (typeof loadListeningRecommendations === 'function') loadListeningRecommendations();
|
||||
if (typeof loadRecommendedArtistsSection === 'function') loadRecommendedArtistsSection();
|
||||
}, 200);
|
||||
}
|
||||
async function loadAdventurousnessDial() {
|
||||
const slider = document.getElementById('adv-dial-slider');
|
||||
if (!slider) return;
|
||||
try {
|
||||
const resp = await fetch('/api/discover/adventurousness');
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
const v = (typeof data.value === 'number') ? data.value : 0.3;
|
||||
slider.value = v;
|
||||
onAdvDialInput(v);
|
||||
} catch (e) { /* non-fatal */ }
|
||||
}
|
||||
|
||||
async function loadDiscoverPage() {
|
||||
console.log('Loading discover page...');
|
||||
|
||||
// Load all sections
|
||||
await Promise.all([
|
||||
loadDiscoverHero(),
|
||||
loadAdventurousnessDial(), // sets the Discover-page dial from config
|
||||
loadListeningRecommendations(), // #913: play-weighted, consensus-ranked picks
|
||||
loadPersonalizedListeningMix(), // #913: playable track mix from those picks
|
||||
loadRecommendedArtistsSection(),
|
||||
|
|
|
|||
|
|
@ -35162,6 +35162,59 @@ div.artist-hero-badge {
|
|||
border-color: rgba(var(--accent-rgb),0.4);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ── Adventurousness dial (Discover page, fancy) ─────────────────────────────
|
||||
Gradient slider (Safe green → accent → hot orange) with a glowing thumb, a rotating
|
||||
compass that swaps with the level, and a live state word. Syncs with Settings → Discovery. */
|
||||
.adv-dial {
|
||||
margin: 0 0 26px;
|
||||
padding: 16px 22px;
|
||||
border-radius: 18px;
|
||||
background: linear-gradient(135deg, rgba(var(--accent-rgb),0.10), rgba(255,255,255,0.02));
|
||||
border: 1px solid rgba(255,255,255,0.07);
|
||||
box-shadow: 0 10px 34px rgba(0,0,0,0.28);
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
grid-template-areas: "head slider" "head hint";
|
||||
align-items: center;
|
||||
column-gap: 26px; row-gap: 4px;
|
||||
}
|
||||
.adv-dial-head { grid-area: head; display: flex; align-items: center; gap: 14px; min-width: 188px; }
|
||||
.adv-dial-icon {
|
||||
width: 50px; height: 50px; border-radius: 14px; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center; font-size: 25px;
|
||||
background: rgba(var(--accent-rgb),0.16);
|
||||
box-shadow: inset 0 0 0 1px rgba(var(--accent-rgb),0.22);
|
||||
transition: transform 0.35s cubic-bezier(0.4,0,0.2,1);
|
||||
}
|
||||
.adv-dial:hover .adv-dial-icon { transform: rotate(-14deg) scale(1.06); }
|
||||
.adv-dial-title { font-size: 15px; font-weight: 700; color: #fff; }
|
||||
.adv-dial-state { font-size: 13px; font-weight: 600; color: var(--accent); margin-top: 2px; transition: color 0.2s; }
|
||||
.adv-dial-slider-wrap { grid-area: slider; display: flex; align-items: center; gap: 14px; }
|
||||
.adv-dial-end { font-size: 12px; font-weight: 600; color: rgba(255,255,255,0.4); white-space: nowrap; }
|
||||
.adv-dial-hint { grid-area: hint; font-size: 12px; color: rgba(255,255,255,0.45); }
|
||||
.adv-dial-slider {
|
||||
flex: 1; height: 8px; border-radius: 5px; cursor: pointer;
|
||||
-webkit-appearance: none; appearance: none; outline: none;
|
||||
background: linear-gradient(90deg, #1DB954 0%, var(--accent) 52%, #ff7a45 100%);
|
||||
}
|
||||
.adv-dial-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none; appearance: none;
|
||||
width: 22px; height: 22px; border-radius: 50%; background: #fff;
|
||||
box-shadow: 0 0 0 4px rgba(var(--accent-rgb),0.28), 0 3px 10px rgba(0,0,0,0.45);
|
||||
cursor: grab; transition: transform 0.15s, box-shadow 0.2s;
|
||||
}
|
||||
.adv-dial-slider::-webkit-slider-thumb:hover { transform: scale(1.16); box-shadow: 0 0 0 6px rgba(var(--accent-rgb),0.32), 0 4px 14px rgba(0,0,0,0.55); }
|
||||
.adv-dial-slider::-webkit-slider-thumb:active { cursor: grabbing; }
|
||||
.adv-dial-slider::-moz-range-thumb {
|
||||
width: 22px; height: 22px; border-radius: 50%; border: none; background: #fff;
|
||||
box-shadow: 0 0 0 4px rgba(var(--accent-rgb),0.28), 0 3px 10px rgba(0,0,0,0.45); cursor: grab;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.adv-dial { grid-template-columns: 1fr; grid-template-areas: "head" "slider" "hint"; row-gap: 12px; }
|
||||
.adv-dial-head { min-width: 0; }
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue