Now Playing modal: full visual redesign + click-art visualizer, sleep timer, up-next

Player-revamp frontend (Phase 1). Brings the Now Playing modal to the approved
mockup look + features:

- Full restyle (override block in style.css): 28px modal radius, stronger
  art-driven ambient glow, 340px rounded art that scales while playing, bold
  28px title, accent artist name, accent FLAC pill, dominant 70px gradient
  play button, accent-gradient progress/volume/visualizer. All driven by the
  existing --accent-rgb / --accent-light-rgb so it follows the settings accent.
- Click album art -> Plexamp-style visualizer takeover, fed by the REAL
  music-synced Web Audio analyser (npStartVisualizerLoop), click again -> art.
- Rich queue rows: album thumbnail + title/artist + duration, equalizer
  animation on the now-playing row, hover-reveal remove.
- Up-next peek below the controls (shows the next queued track).
- Sleep timer (cycles 15/30/60m, real setTimeout -> handleStop).
- Crossfade toggle present (visual state + persisted pref; the dual-audio
  crossfade engine is the next step, not yet wired).

Frontend-only; verified live in-browser by Boulder. No backend/test surface.
pull/761/head
BoulderBadgeDad 3 weeks ago
parent ca90c6ae6f
commit 3461d9235b

@ -7017,13 +7017,18 @@
<div class="np-body">
<!-- Left: album art + track info -->
<div class="np-left">
<div class="np-album-art-container">
<div class="np-album-art-container" id="np-album-art-container" title="Click for visualizer">
<img class="np-album-art hidden" id="np-album-art" src="" alt="Album Art">
<div class="np-album-art-placeholder" id="np-album-art-placeholder">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><line x1="12" y1="2" x2="12" y2="5"/><line x1="12" y1="19" x2="12" y2="22"/>
</svg>
</div>
<!-- Big music-synced visualizer takeover (Plexamp-style); click art to toggle -->
<div class="np-art-viz" id="np-art-viz" aria-hidden="true"></div>
<div class="np-art-hint" id="np-art-hint" title="Visualizer">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><rect x="3" y="10" width="3" height="11" rx="1"/><rect x="10.5" y="4" width="3" height="17" rx="1"/><rect x="18" y="13" width="3" height="8" rx="1"/></svg>
</div>
</div>
<div class="np-track-info">
<div class="np-track-title" id="np-track-title">No track</div>
@ -7041,6 +7046,16 @@
</div>
<!-- Right: controls -->
<div class="np-right">
<div class="np-util-row">
<button class="np-util-btn" id="np-sleep-btn" title="Sleep timer">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
<span id="np-sleep-label">Sleep</span>
</button>
<button class="np-util-btn" id="np-crossfade-btn" title="Crossfade between tracks">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 8l-4 4 4 4"/><path d="M17 8l4 4-4 4"/><line x1="3" y1="12" x2="21" y2="12"/></svg>
<span>Crossfade</span>
</button>
</div>
<div class="np-progress-section">
<div class="np-progress-bar-container">
<div class="np-progress-track">
@ -7085,6 +7100,15 @@
<input type="range" class="np-volume-slider" id="np-volume-slider" min="0" max="100" value="70">
</div>
</div>
<!-- Up-next peek (hidden when queue has no next track) -->
<div class="np-upnext hidden" id="np-upnext">
<span class="np-upnext-label">Next</span>
<img class="np-upnext-art" id="np-upnext-art" alt="">
<div class="np-upnext-info">
<div class="np-upnext-title" id="np-upnext-title"></div>
<div class="np-upnext-artist" id="np-upnext-artist"></div>
</div>
</div>
<div class="np-stop-row">
<button class="np-btn np-btn-stop" id="np-stop-btn" title="Stop playback">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><rect x="4" y="4" width="16" height="16" rx="2"/></svg>

@ -1382,6 +1382,9 @@ let npAnalyser = null;
let npMediaSource = null;
let npVizAnimFrame = null;
let npVizInitialized = false;
let npCrossfadeOn = false;
let npSleepMinutes = 0; // 0 = off
let npSleepTimerId = null;
function npQueueHasNext() {
if (npQueue.length === 0) return false;
@ -1451,6 +1454,28 @@ function initExpandedPlayer() {
// Control handlers
playBtn.addEventListener('click', () => { togglePlayback(); });
stopBtn.addEventListener('click', async () => { await handleStop(); closeNowPlayingModal(); });
// Click album art → toggle the music-synced visualizer takeover
const artContainer = document.getElementById('np-album-art-container');
if (artContainer) {
artContainer.addEventListener('click', () => {
const on = artContainer.classList.toggle('viz-on');
if (on) { npBuildArtViz(); npInitVisualizer(); npStartVisualizerLoop(); }
});
}
// Sleep timer — cycles off → 15 → 30 → 60 min → off
const sleepBtn = document.getElementById('np-sleep-btn');
if (sleepBtn) sleepBtn.addEventListener('click', npCycleSleepTimer);
// Crossfade toggle (visual state now; dual-audio crossfade wired later)
const xfadeBtn = document.getElementById('np-crossfade-btn');
if (xfadeBtn) xfadeBtn.addEventListener('click', () => {
npCrossfadeOn = !npCrossfadeOn;
xfadeBtn.classList.toggle('active', npCrossfadeOn);
try { localStorage.setItem('soulsync-crossfade', npCrossfadeOn ? '1' : '0'); } catch (e) {}
});
shuffleBtn.addEventListener('click', handleNpShuffle);
repeatBtn.addEventListener('click', handleNpRepeat);
muteBtn.addEventListener('click', handleNpMuteToggle);
@ -1588,6 +1613,10 @@ function syncExpandedPlayerUI() {
const viz = document.getElementById('np-visualizer');
if (viz) viz.classList.toggle('playing', isPlaying);
// Album-art scale-on-play (Phase A restyle — CSS keys off .np-modal.playing)
const npModalEl = document.querySelector('.np-modal');
if (npModalEl) npModalEl.classList.toggle('playing', isPlaying);
// Queue
renderNpQueue();
updateNpPrevNextButtons();
@ -2074,6 +2103,18 @@ function renderNpQueue() {
item.className = 'np-queue-item' + (i === npQueueIndex ? ' active' : '');
item.onclick = () => playQueueItem(i);
// Album thumbnail
const art = document.createElement('img');
art.className = 'np-queue-item-art';
art.alt = '';
if (track.image_url) {
art.src = track.image_url;
art.onerror = () => { art.style.visibility = 'hidden'; };
} else {
art.style.visibility = 'hidden';
}
item.appendChild(art);
const info = document.createElement('div');
info.className = 'np-queue-item-info';
@ -2089,6 +2130,19 @@ function renderNpQueue() {
info.appendChild(artist);
item.appendChild(info);
// Active row → equalizer animation; others → duration
if (i === npQueueIndex) {
const eq = document.createElement('div');
eq.className = 'np-queue-item-eq';
eq.innerHTML = '<i></i><i></i><i></i>';
item.appendChild(eq);
} else if (track.duration) {
const dur = document.createElement('span');
dur.className = 'np-queue-item-duration';
dur.textContent = formatTime(track.duration);
item.appendChild(dur);
}
const removeBtn = document.createElement('button');
removeBtn.className = 'np-queue-item-remove';
removeBtn.innerHTML = '&#10005;';
@ -2101,6 +2155,48 @@ function renderNpQueue() {
listEl.appendChild(item);
});
npUpdateUpNext();
}
// Up-next peek: show the track that plays after the current one.
function npUpdateUpNext() {
const box = document.getElementById('np-upnext');
if (!box) return;
const next = npQueue[npQueueIndex + 1];
if (!next) { box.classList.add('hidden'); return; }
box.classList.remove('hidden');
const art = document.getElementById('np-upnext-art');
const title = document.getElementById('np-upnext-title');
const artist = document.getElementById('np-upnext-artist');
if (title) title.textContent = next.title || 'Unknown Track';
if (artist) artist.textContent = next.artist || 'Unknown Artist';
if (art) {
if (next.image_url) { art.src = next.image_url; art.style.visibility = ''; art.onerror = () => { art.style.visibility = 'hidden'; }; }
else { art.style.visibility = 'hidden'; }
}
}
// Sleep timer: cycle off → 15 → 30 → 60 → off; stops playback when it fires.
function npCycleSleepTimer() {
const steps = [0, 15, 30, 60];
npSleepMinutes = steps[(steps.indexOf(npSleepMinutes) + 1) % steps.length];
const btn = document.getElementById('np-sleep-btn');
const label = document.getElementById('np-sleep-label');
if (npSleepTimerId) { clearTimeout(npSleepTimerId); npSleepTimerId = null; }
if (npSleepMinutes > 0) {
if (label) label.textContent = `Sleep ${npSleepMinutes}m`;
if (btn) btn.classList.add('active');
npSleepTimerId = setTimeout(() => {
handleStop();
npSleepMinutes = 0;
if (label) label.textContent = 'Sleep';
if (btn) btn.classList.remove('active');
}, npSleepMinutes * 60 * 1000);
} else {
if (label) label.textContent = 'Sleep';
if (btn) btn.classList.remove('active');
}
}
function updateNpPrevNextButtons() {
@ -2220,6 +2316,19 @@ function npInitVisualizer() {
}
}
// Number of bars in the big album-art visualizer takeover.
const NP_ART_VIZ_BAR_COUNT = 28;
function npBuildArtViz() {
const container = document.getElementById('np-art-viz');
if (!container || container.children.length > 0) return;
for (let i = 0; i < NP_ART_VIZ_BAR_COUNT; i++) {
const bar = document.createElement('div');
bar.className = 'np-art-viz-bar';
container.appendChild(bar);
}
}
function npStartVisualizerLoop() {
if (npVizAnimFrame) return; // Already running
if (!npAnalyser) return; // No analyser — CSS fallback handles it
@ -2229,7 +2338,6 @@ function npStartVisualizerLoop() {
}
const bars = document.querySelectorAll('.np-viz-bar');
if (bars.length === 0) return;
const bufferLength = npAnalyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
@ -2237,14 +2345,25 @@ function npStartVisualizerLoop() {
npVizAnimFrame = requestAnimationFrame(draw);
npAnalyser.getByteFrequencyData(dataArray);
// Map 7 bars to frequency bins (skip bin 0 which is DC offset)
const binCount = Math.min(bufferLength - 1, 7);
// Map 7 transport bars to frequency bins (skip bin 0 = DC offset)
for (let i = 0; i < bars.length; i++) {
const binIndex = Math.min(i + 1, bufferLength - 1);
const value = dataArray[binIndex] / 255; // 0..1
const scale = Math.max(0.08, value); // minimum visible height
bars[i].style.transform = `scaleY(${scale})`;
}
// Big album-art visualizer (when toggled on) — same real analyser,
// spread across more bars for a fuller spectrum.
const artBars = document.querySelectorAll('.np-art-viz-bar');
if (artBars.length) {
const span = bufferLength - 1;
for (let i = 0; i < artBars.length; i++) {
const binIndex = 1 + Math.floor((i / artBars.length) * span);
const value = dataArray[Math.min(binIndex, bufferLength - 1)] / 255;
artBars[i].style.height = Math.max(6, value * 100) + '%';
}
}
}
draw();
}

@ -48581,6 +48581,192 @@ textarea.enhanced-meta-field-input {
background: rgba(255, 80, 80, 0.1);
}
/*
NOW PLAYING full redesign (player revamp). Comprehensive override of the
.np-* modal styling to the approved mockup look. !important is used through-
out to guarantee these win over the original block above regardless of
source order. Accent-driven via --accent-rgb / --accent-light-rgb.
*/
.np-modal {
width: min(960px, 94vw) !important;
max-width: 960px !important;
border-radius: 28px !important;
background: linear-gradient(180deg, rgba(26,26,28,0.97) 0%, rgba(11,11,12,0.99) 100%) !important;
border: 1px solid rgba(255,255,255,0.08) !important;
box-shadow: 0 40px 120px rgba(0,0,0,0.72),
0 0 0 1px rgba(255,255,255,0.03) inset,
0 1px 0 rgba(255,255,255,0.06) inset !important;
}
.np-modal::before {
top: -30% !important;
height: 72% !important;
background: radial-gradient(ellipse at 28% 18%,
rgba(var(--np-ambient-r), var(--np-ambient-g), var(--np-ambient-b), 0.45) 0%,
rgba(var(--np-ambient-r), var(--np-ambient-g), var(--np-ambient-b), 0.12) 38%,
transparent 68%) !important;
filter: blur(48px) !important;
}
.np-body { padding: 54px 44px 38px !important; gap: 48px !important; align-items: flex-start !important; }
/* ── Album art: big, rounded, accent depth, scales while playing ── */
.np-album-art-container {
width: 340px !important;
height: 340px !important;
border-radius: 22px !important;
box-shadow: 0 24px 70px rgba(0,0,0,0.6), 0 6px 22px rgba(var(--accent-rgb),0.16) !important;
transition: transform 0.5s cubic-bezier(0.16,1,0.3,1), box-shadow 0.5s !important;
}
.np-modal.playing .np-album-art-container {
transform: scale(1.02) !important;
box-shadow: 0 30px 92px rgba(0,0,0,0.66), 0 12px 40px rgba(var(--accent-rgb),0.28) !important;
}
/* ── Track meta ── */
.np-track-info { text-align: left !important; }
.np-track-title { font-size: 28px !important; font-weight: 800 !important; letter-spacing: -0.02em !important; line-height: 1.1 !important; }
.np-artist-name { font-size: 15px !important; font-weight: 600 !important; color: rgb(var(--accent-light-rgb)) !important; }
.np-album-name { font-size: 13px !important; color: rgba(255,255,255,0.4) !important; }
/* ── Format badges → accent pills ── */
.np-format-badges { display: flex !important; gap: 8px !important; flex-wrap: wrap !important; margin-top: 16px !important; }
.np-format-badge {
font-size: 10.5px !important; font-weight: 700 !important; letter-spacing: 0.04em !important; text-transform: uppercase !important;
padding: 5px 11px !important; border-radius: 999px !important;
background: rgba(255,255,255,0.07) !important; border: 1px solid rgba(255,255,255,0.08) !important;
color: rgba(255,255,255,0.62) !important;
}
.np-format-badge.flac {
color: rgb(var(--accent-light-rgb)) !important;
border-color: rgba(var(--accent-rgb),0.4) !important;
background: rgba(var(--accent-rgb),0.12) !important;
}
/* ── Progress / volume: thicker track, accent gradient fill, glow on hover ── */
.np-progress-track, .np-volume-track { height: 6px !important; border-radius: 999px !important; background: rgba(255,255,255,0.12) !important; }
.np-progress-fill, .np-volume-fill {
background: linear-gradient(90deg, rgb(var(--accent-rgb)), rgb(var(--accent-light-rgb))) !important;
border-radius: 999px !important;
}
.np-progress-bar-container:hover .np-progress-fill { box-shadow: 0 0 12px rgba(var(--accent-rgb),0.6) !important; }
.np-time-display { font-variant-numeric: tabular-nums !important; }
/* ── Transport: dominant gradient play button ── */
.np-controls-row { gap: 24px !important; }
.np-btn {
width: 46px !important; height: 46px !important; border-radius: 50% !important;
color: rgba(255,255,255,0.62) !important; transition: all 0.16s ease !important;
}
.np-btn:hover { color: #fff !important; background: rgba(255,255,255,0.07) !important; transform: scale(1.08) !important; }
.np-btn-play {
width: 70px !important; height: 70px !important; border-radius: 50% !important;
background: linear-gradient(180deg, rgb(var(--accent-light-rgb)), rgb(var(--accent-rgb))) !important;
color: #06140b !important;
box-shadow: 0 8px 30px rgba(var(--accent-rgb),0.5), inset 0 1px 0 rgba(255,255,255,0.35) !important;
}
.np-btn-play:hover {
filter: none !important;
transform: scale(1.06) !important;
background: linear-gradient(180deg, rgb(var(--accent-light-rgb)), rgb(var(--accent-rgb))) !important;
box-shadow: 0 12px 40px rgba(var(--accent-rgb),0.66), inset 0 1px 0 rgba(255,255,255,0.45) !important;
}
.np-btn-shuffle.active, .np-btn-repeat.active { color: rgb(var(--accent-light-rgb)) !important; }
.np-btn-shuffle.active::after, .np-btn-repeat.active::after { background: rgb(var(--accent-light-rgb)) !important; }
/* ── Visualizer bars: accent gradient ── */
.np-viz-bar {
background: linear-gradient(180deg, rgb(var(--accent-light-rgb)), rgba(var(--accent-rgb),0.35)) !important;
border-radius: 3px !important;
}
/* ── Queue rows: rounded, hover, accent active row + accent title ── */
.np-queue-item { border-radius: 12px !important; transition: background 0.14s !important; }
.np-queue-item:hover { background: rgba(255,255,255,0.05) !important; }
.np-queue-item.active { background: rgba(var(--accent-rgb),0.12) !important; }
.np-queue-item.active .np-queue-item-title { color: rgb(var(--accent-light-rgb)) !important; }
.np-radio-btn.active, .np-queue-toggle.active { color: rgb(var(--accent-light-rgb)) !important; }
/* ── Close button + lyrics active line ── */
.np-close-btn:hover { color: #fff !important; background: rgba(255,255,255,0.1) !important; }
.np-lyrics-line.active { color: rgb(var(--accent-light-rgb)) !important; font-weight: 700 !important; }
/* ── Click-art → music-synced visualizer takeover (Plexamp-style) ── */
.np-album-art-container { cursor: pointer; }
.np-art-hint {
position: absolute; bottom: 12px; right: 12px; z-index: 4;
width: 34px; height: 34px; border-radius: 50%;
background: rgba(0,0,0,0.5); backdrop-filter: blur(8px);
display: grid; place-items: center; color: #fff;
opacity: 0; transition: opacity 0.2s ease; pointer-events: none;
}
.np-album-art-container:hover .np-art-hint { opacity: 1; }
.np-album-art-container.viz-on .np-art-hint { opacity: 1; color: rgb(var(--accent-light-rgb)); }
.np-art-viz {
position: absolute; inset: 0; z-index: 3;
display: none; align-items: center; justify-content: center; gap: 5px;
background: radial-gradient(circle at 50% 60%, rgba(var(--accent-rgb),0.20), rgba(0,0,0,0.88));
}
.np-album-art-container.viz-on .np-art-viz { display: flex; }
.np-album-art-container.viz-on .np-album-art,
.np-album-art-container.viz-on .np-album-art-placeholder { opacity: 0; transition: opacity 0.35s ease; }
.np-art-viz-bar {
width: 7px; border-radius: 5px; height: 12%;
background: linear-gradient(180deg, rgb(var(--accent-light-rgb)), rgba(var(--accent-rgb),0.35));
transform-origin: bottom; transition: height 0.08s linear;
}
/* ── Sleep / Crossfade utility row (top-right of controls) ── */
.np-util-row { display: flex; gap: 8px; justify-content: flex-end; margin-bottom: 4px; }
.np-util-btn {
display: flex; align-items: center; gap: 6px;
font-size: 11px; font-weight: 700; padding: 6px 11px; border-radius: 999px;
background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08);
color: rgba(255,255,255,0.4); cursor: pointer; transition: all 0.16s ease;
}
.np-util-btn:hover { color: rgba(255,255,255,0.7); border-color: rgba(255,255,255,0.18); }
.np-util-btn.active {
color: rgb(var(--accent-light-rgb));
background: rgba(var(--accent-rgb),0.12);
border-color: rgba(var(--accent-rgb),0.32);
}
/* ── Up-next peek ── */
.np-upnext {
display: flex; align-items: center; gap: 11px;
padding: 10px 12px; border-radius: 14px;
background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08);
}
.np-upnext.hidden { display: none; }
.np-upnext-label {
font-size: 10px; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase;
color: rgba(255,255,255,0.38); flex-shrink: 0;
}
.np-upnext-art { width: 38px; height: 38px; border-radius: 8px; object-fit: cover; flex-shrink: 0; background: rgba(255,255,255,0.05); }
.np-upnext-info { min-width: 0; }
.np-upnext-title { font-size: 13px; font-weight: 600; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.np-upnext-artist { font-size: 11.5px; color: rgba(255,255,255,0.4); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* ── Rich queue rows: thumbnail + meta + duration/eq + remove ── */
.np-queue-item {
display: grid !important;
grid-template-columns: 44px 1fr auto auto !important;
align-items: center !important; gap: 13px !important;
padding: 8px 10px !important;
}
.np-queue-item-art { width: 44px; height: 44px; border-radius: 8px; object-fit: cover; background: rgba(255,255,255,0.05); }
.np-queue-item-info { min-width: 0; }
.np-queue-item-title { font-size: 14px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.np-queue-item-artist { font-size: 12px; color: rgba(255,255,255,0.4); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.np-queue-item-duration { font-size: 12px; font-variant-numeric: tabular-nums; color: rgba(255,255,255,0.38); }
.np-queue-item-eq { display: inline-flex; align-items: flex-end; gap: 2px; height: 14px; }
.np-queue-item-eq i { width: 3px; border-radius: 2px; background: rgb(var(--accent-light-rgb)); animation: npQueueEq 0.9s ease-in-out infinite; }
.np-queue-item-eq i:nth-child(2) { animation-delay: 0.2s; }
.np-queue-item-eq i:nth-child(3) { animation-delay: 0.4s; }
@keyframes npQueueEq { 0%,100% { height: 30%; } 50% { height: 100%; } }
.np-queue-item-remove { opacity: 0; transition: opacity 0.14s, color 0.14s; }
.np-queue-item:hover .np-queue-item-remove { opacity: 1; }
/* Queue button in enhanced track table */
.col-queue {
width: 36px;

Loading…
Cancel
Save