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:
parent
43fbab5704
commit
c50465984f
3 changed files with 136 additions and 86 deletions
|
|
@ -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) -->
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue