discovery: replace the slider with a living wave dial (animated)

Scrapped the basic slider for a custom animated control matching the vision: a draggable orb rides an
SVG line that is redrawn every frame (rAF). At the left (value 0) the line is green and waves gently —
"artists you already like", alive but calm; drag the orb right and the colour shifts through the warm
spectrum (green -> yellow -> orange -> red) while the wave grows taller and more erratic (a detuned
second harmonic). The orb glows the current colour and rides the wave; the state word recolours too.
Releasing saves (POST /api/discover/adventurousness) and re-fetches both rec rows. The loop skips work
while the page is hidden. Still one source of truth with the Settings slider.

64 script-integrity tests green.
This commit is contained in:
BoulderBadgeDad 2026-06-29 16:41:12 -07:00
parent 43fbab5704
commit c50465984f
3 changed files with 136 additions and 86 deletions

View file

@ -3146,20 +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"
<!-- 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-dial-icon" id="adv-dial-icon">🧭</div>
<div class="adv-dial-label">
<span class="adv-dial-title">Adventurousness</span>
<span class="adv-dial-state" id="adv-dial-state">Balanced</span>
<div class="adv-wave-head">
<span class="adv-wave-title">Adventurousness</span>
<span class="adv-wave-state" id="adv-wave-state">Balanced</span>
</div>
<div class="adv-wave-track" id="adv-wave-track">
<svg class="adv-wave-svg" id="adv-wave-svg" viewBox="0 0 1000 80" preserveAspectRatio="none" aria-hidden="true">
<path id="adv-wave-path" fill="none" stroke-width="3" stroke-linecap="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>
<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 class="adv-dial-val" id="adv-dial-val">30%</div>
</div>
<!-- Recommended For You Section (similar-artists graph) -->

View file

@ -22,27 +22,69 @@ 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.
// ── Adventurousness — living wave dial (Discover page) ──────────────────────────────────────
// A draggable orb rides an animated line. At the left (value 0) the line is green and waves gently
// ("artists you already like"); dragging right shifts the colour through the spectrum to red and the
// wave grows bigger + more erratic. Drawn each frame with rAF. Shares discover.adventurousness with
// the Settings slider, so the two stay in sync.
const _advWave = { value: 0.3, phase: 0, raf: null, dragging: false };
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: '🔮' };
if (v < 0.12) return 'Playing it safe';
if (v < 0.40) return 'Balanced';
if (v < 0.70) return 'Adventurous';
return 'Deep cuts only';
}
function onAdvDialInput(value) {
const v = parseFloat(value);
const s = _advState(v);
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;
const valEl = document.getElementById('adv-dial-val');
if (valEl) valEl.textContent = Math.round(v * 100) + '%';
// green (120°) → red (0°) through the warm spectrum (yellow, orange) as the orb moves right.
function _advColor(v, light) {
const hue = 120 * (1 - Math.max(0, Math.min(1, v)));
return `hsl(${hue.toFixed(0)}, 85%, ${light || 55}%)`;
}
// Wave height (viewBox units, centre 40) at position u (0..1) for adventurousness v.
function _advWaveY(u, v) {
const amp = 2 + v * 22; // gentle ripple at 0, tall waves at 1
const freq = 1.1 + v * 3.2; // more cycles as it gets adventurous
let y = 40 + amp * Math.sin(freq * u * Math.PI * 2 + _advWave.phase);
if (v > 0) { // a detuned second harmonic adds the erratic wobble
y += v * amp * 0.55 * Math.sin(freq * 2.4 * u * Math.PI * 2 + _advWave.phase * 1.7 + 1.3);
}
return y;
}
function _advDraw() {
const path = document.getElementById('adv-wave-path');
const track = document.getElementById('adv-wave-track');
if (!path || !track) { _advWave.raf = null; return; }
if (track.offsetParent !== null) { // skip the work while the Discover page is hidden
const v = _advWave.value;
_advWave.phase += 0.03 + v * 0.07; // waves faster the more adventurous
let d = '';
const N = 90;
for (let i = 0; i <= N; i++) {
const u = i / N;
d += (i === 0 ? 'M ' : ' L ') + (u * 1000).toFixed(1) + ' ' + _advWaveY(u, v).toFixed(1);
}
path.setAttribute('d', d);
path.setAttribute('stroke', _advColor(v));
const orb = document.getElementById('adv-wave-orb');
if (orb) orb.style.top = (_advWaveY(v, v) / 80 * 100).toFixed(2) + '%'; // orb rides the wave
}
_advWave.raf = requestAnimationFrame(_advDraw);
}
function _advApply(v) {
v = Math.max(0, Math.min(1, v));
_advWave.value = v;
const orb = document.getElementById('adv-wave-orb');
if (orb) {
orb.style.left = (v * 100).toFixed(2) + '%';
const c = _advColor(v, 58);
orb.style.background = c;
orb.style.boxShadow = `0 0 18px 2px ${c}, 0 0 0 5px rgba(255,255,255,0.08), inset 0 0 0 2px rgba(255,255,255,0.55)`;
}
const stateEl = document.getElementById('adv-wave-state');
if (stateEl) { stateEl.textContent = _advState(v); stateEl.style.color = _advColor(v, 62); }
}
let _advCommitTimer = null;
function onAdvDialCommit(value) {
const v = parseFloat(value);
function _advCommit(v) {
clearTimeout(_advCommitTimer);
_advCommitTimer = setTimeout(async () => {
try {
@ -54,19 +96,44 @@ function onAdvDialCommit(value) {
// 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);
}, 220);
}
function _advInitDrag() {
const track = document.getElementById('adv-wave-track');
if (!track || track._advWired) return;
track._advWired = true;
const fromX = (clientX) => {
const r = track.getBoundingClientRect();
return r.width ? (clientX - r.left) / r.width : 0;
};
track.addEventListener('pointerdown', (e) => {
_advWave.dragging = true;
try { track.setPointerCapture(e.pointerId); } catch (_) { /* ignore */ }
_advApply(fromX(e.clientX));
});
track.addEventListener('pointermove', (e) => { if (_advWave.dragging) _advApply(fromX(e.clientX)); });
const end = (e) => {
if (!_advWave.dragging) return;
_advWave.dragging = false;
_advApply(fromX(e.clientX));
_advCommit(_advWave.value);
};
track.addEventListener('pointerup', end);
track.addEventListener('pointercancel', end);
}
async function loadAdventurousnessDial() {
const slider = document.getElementById('adv-dial-slider');
if (!slider) return;
const track = document.getElementById('adv-wave-track');
if (!track) return;
_advInitDrag();
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);
if (resp.ok) {
const data = await resp.json();
if (typeof data.value === 'number') _advWave.value = data.value;
}
} catch (e) { /* non-fatal */ }
_advApply(_advWave.value);
if (!_advWave.raf) _advWave.raf = requestAnimationFrame(_advDraw);
}
async function loadDiscoverPage() {

View file

@ -35163,62 +35163,40 @@ div.artist-hero-badge {
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 {
display: flex; align-items: center; gap: 16px;
/* 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 {
max-width: 880px;
margin: 0 0 26px;
padding: 13px 20px;
padding: 16px 22px 14px;
border-radius: 16px;
background: linear-gradient(135deg, rgba(var(--accent-rgb),0.12), rgba(255,255,255,0.02));
border: 1px solid rgba(255,255,255,0.08);
background: linear-gradient(135deg, rgba(255,255,255,0.035), rgba(255,255,255,0.01));
border: 1px solid rgba(255,255,255,0.07);
box-shadow: 0 8px 28px rgba(0,0,0,0.25);
}
.adv-dial-icon {
width: 44px; height: 44px; border-radius: 12px; flex-shrink: 0;
display: flex; align-items: center; justify-content: center; font-size: 22px;
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-wave-head { display: flex; align-items: baseline; gap: 12px; }
.adv-wave-title { font-size: 14px; font-weight: 700; color: #fff; }
.adv-wave-state { font-size: 12px; font-weight: 700; transition: color 0.25s; }
.adv-wave-track {
position: relative; height: 84px; margin: 4px 0 2px;
cursor: grab; touch-action: none;
}
.adv-dial:hover .adv-dial-icon { transform: rotate(-14deg) scale(1.06); }
.adv-dial-label { display: flex; flex-direction: column; min-width: 142px; flex-shrink: 0; }
.adv-dial-title { font-size: 14px; font-weight: 700; color: #fff; line-height: 1.2; }
.adv-dial-state { font-size: 12px; font-weight: 600; color: var(--accent); margin-top: 1px; transition: color 0.2s; }
.adv-dial-end { font-size: 11px; font-weight: 600; color: rgba(255,255,255,0.4); white-space: nowrap; flex-shrink: 0; }
.adv-dial-val { min-width: 44px; text-align: right; font-size: 14px; font-weight: 800; color: var(--accent); flex-shrink: 0; }
/* Track styled via the pseudo-elements (the input's own background does NOT render as the track when
appearance is none on Chromium that was the invisible-track bug). */
.adv-dial-slider {
flex: 1; min-width: 100px; height: 8px; cursor: pointer;
-webkit-appearance: none; appearance: none; outline: none; background: transparent;
.adv-wave-track:active { cursor: grabbing; }
.adv-wave-svg { width: 100%; height: 100%; display: block; overflow: visible; }
.adv-wave-orb {
position: absolute; top: 50%; left: 30%;
width: 20px; height: 20px; border-radius: 50%;
transform: translate(-50%, -50%);
background: #1DB954;
box-shadow: 0 0 18px 2px #1DB954, 0 0 0 5px rgba(255,255,255,0.08), inset 0 0 0 2px rgba(255,255,255,0.55);
pointer-events: none; /* the track captures the pointer; the orb is purely visual */
will-change: left, top;
}
.adv-dial-slider::-webkit-slider-runnable-track {
height: 8px; border-radius: 5px;
background: linear-gradient(90deg, #1DB954 0%, var(--accent) 52%, #ff7a45 100%);
}
.adv-dial-slider::-moz-range-track {
height: 8px; border-radius: 5px;
background: linear-gradient(90deg, #1DB954 0%, var(--accent) 52%, #ff7a45 100%);
}
.adv-dial-slider::-webkit-slider-thumb {
-webkit-appearance: none; appearance: none;
width: 20px; height: 20px; margin-top: -6px; border-radius: 50%; background: #fff;
box-shadow: 0 0 0 4px rgba(var(--accent-rgb),0.28), 0 3px 9px 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.18); box-shadow: 0 0 0 6px rgba(var(--accent-rgb),0.34), 0 4px 12px rgba(0,0,0,0.55); }
.adv-dial-slider::-webkit-slider-thumb:active { cursor: grabbing; }
.adv-dial-slider::-moz-range-thumb {
width: 20px; height: 20px; border-radius: 50%; border: none; background: #fff;
box-shadow: 0 0 0 4px rgba(var(--accent-rgb),0.28), 0 3px 9px rgba(0,0,0,0.45); cursor: grab;
}
@media (max-width: 760px) {
.adv-dial { flex-wrap: wrap; gap: 10px 14px; }
.adv-dial-label { min-width: 0; }
.adv-dial-slider { flex: 1 1 100%; order: 5; }
.adv-wave-ends {
display: flex; justify-content: space-between;
font-size: 11px; font-weight: 500; color: rgba(255,255,255,0.35);
}
/* Watchlist toggle on recommended-artist .ya-cards corner pill, appears on hover,