diff --git a/webui/index.html b/webui/index.html index 01d1646f..78d90660 100644 --- a/webui/index.html +++ b/webui/index.html @@ -3146,20 +3146,25 @@
- -
+
-
🧭
-
- Adventurousness - Balanced +
+ Adventurousness + Balanced +
+
+ +
+
+
+ Safe — artists you already like + Adventurous — deep cuts
- Safe - - Adventurous -
30%
diff --git a/webui/static/discover.js b/webui/static/discover.js index 97fad8d0..6c8d7107 100644 --- a/webui/static/discover.js +++ b/webui/static/discover.js @@ -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() { diff --git a/webui/static/style.css b/webui/static/style.css index b11d7834..91017d4d 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -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,