From ffbe669c67bd62e437fee82d3c49cfbe4e679001 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Sat, 30 May 2026 11:46:31 -0700 Subject: [PATCH] Now Playing: vibrant album-art color extraction + drag-to-reorder queue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two next-level player features (frontend-only): 1. Album-art ambient color — replaced the flat pixel AVERAGE (which muddied every cover to grey-brown) with dominant-VIBRANT extraction: coarse histogram binning weighted by saturation² × population, then a punch-up pass (boost saturation ~1.3x, floor brightness) so the modal glow reads as the cover's real standout color, Apple-Music style. Feeds the existing --np-ambient-r/g/b hooks. 2. Drag-to-reorder queue — queue rows are now draggable; npReorderQueue moves the item AND recomputes npQueueIndex so the currently-playing track stays correctly tracked after a reorder. Accent drop-line indicator, grab cursor, dragging opacity. Verified live in-browser by Boulder. --- webui/static/media-player.js | 126 +++++++++++++++++++++++++++++++---- webui/static/style.css | 6 ++ 2 files changed, 118 insertions(+), 14 deletions(-) diff --git a/webui/static/media-player.js b/webui/static/media-player.js index d489315f..dac1a0bd 100644 --- a/webui/static/media-player.js +++ b/webui/static/media-player.js @@ -1735,24 +1735,46 @@ function npExtractAmbientColor(imgEl) { try { const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); - canvas.width = 50; - canvas.height = 50; - ctx.drawImage(imgEl, 0, 0, 50, 50); - const data = ctx.getImageData(0, 0, 50, 50).data; - let rSum = 0, gSum = 0, bSum = 0, count = 0; + canvas.width = 64; + canvas.height = 64; + ctx.drawImage(imgEl, 0, 0, 64, 64); + const data = ctx.getImageData(0, 0, 64, 64).data; + + // Dominant VIBRANT color, not a flat average (averaging muddies to + // grey-brown). Bin colors into a coarse 4-bit-per-channel histogram, + // weight each bin by saturation² × pixel-count so a punchy accent in + // the cover wins over a large dull background. Apple-Music-style. + const bins = new Map(); for (let i = 0; i < data.length; i += 16) { // sample every 4th pixel - const r = data[i], g = data[i + 1], b = data[i + 2]; + const r = data[i], g = data[i + 1], b = data[i + 2], a = data[i + 3]; + if (a < 128) continue; + const max = Math.max(r, g, b), min = Math.min(r, g, b); const brightness = (r + g + b) / 3; - if (brightness > 20 && brightness < 230) { - rSum += r; gSum += g; bSum += b; count++; - } - } - if (count > 0) { + if (brightness < 24 || brightness > 240) continue; // skip near-black/white + const sat = max === 0 ? 0 : (max - min) / max; // 0..1 + const key = ((r >> 4) << 8) | ((g >> 4) << 4) | (b >> 4); + const weight = (0.15 + sat * sat) ; // floor so greys still count a little + const bin = bins.get(key); + if (bin) { bin.r += r; bin.g += g; bin.b += b; bin.n++; bin.w += weight; } + else bins.set(key, { r, g, b, n: 1, w: weight }); + } + let best = null, bestScore = -1; + for (const bin of bins.values()) { + const score = bin.w; // saturation-weighted population + if (score > bestScore) { bestScore = score; best = bin; } + } + if (best) { + let r = Math.round(best.r / best.n); + let g = Math.round(best.g / best.n); + let b = Math.round(best.b / best.n); + // Nudge toward vivid: lift saturation/brightness a touch so the + // glow reads as a color, not a wash. + [r, g, b] = npPunchUpColor(r, g, b); const modal = document.querySelector('.np-modal'); if (modal) { - modal.style.setProperty('--np-ambient-r', Math.round(rSum / count)); - modal.style.setProperty('--np-ambient-g', Math.round(gSum / count)); - modal.style.setProperty('--np-ambient-b', Math.round(bSum / count)); + modal.style.setProperty('--np-ambient-r', r); + modal.style.setProperty('--np-ambient-g', g); + modal.style.setProperty('--np-ambient-b', b); } } } catch (e) { @@ -1760,6 +1782,24 @@ function npExtractAmbientColor(imgEl) { } } +// Lift a color toward vividness for the ambient glow (boost saturation, +// floor brightness) without fully desaturating dark/pastel covers. +function npPunchUpColor(r, g, b) { + const max = Math.max(r, g, b), min = Math.min(r, g, b); + if (max === min) return [r, g, b]; // grey — leave it + // Pull each channel away from the mid to boost perceived saturation ~1.3x. + const mid = (max + min) / 2; + const boost = 1.3; + let nr = Math.round(mid + (r - mid) * boost); + let ng = Math.round(mid + (g - mid) * boost); + let nb = Math.round(mid + (b - mid) * boost); + // Floor overall brightness so very dark covers still glow. + const bright = (nr + ng + nb) / 3; + if (bright < 70) { const lift = 70 / Math.max(bright, 1); nr *= lift; ng *= lift; nb *= lift; } + const clamp = v => Math.max(0, Math.min(255, Math.round(v))); + return [clamp(nr), clamp(ng), clamp(nb)]; +} + function npResetAmbientGlow() { const modal = document.querySelector('.np-modal'); if (modal) { @@ -2103,6 +2143,14 @@ function renderNpQueue() { item.className = 'np-queue-item' + (i === npQueueIndex ? ' active' : ''); item.onclick = () => playQueueItem(i); + // Drag-to-reorder + item.draggable = true; + item.dataset.qindex = i; + item.addEventListener('dragstart', npQueueDragStart); + item.addEventListener('dragover', npQueueDragOver); + item.addEventListener('drop', npQueueDrop); + item.addEventListener('dragend', npQueueDragEnd); + // Album thumbnail const art = document.createElement('img'); art.className = 'np-queue-item-art'; @@ -2159,6 +2207,56 @@ function renderNpQueue() { npUpdateUpNext(); } +// ── Queue drag-to-reorder ── +let npDragFromIndex = null; + +function npQueueDragStart(e) { + npDragFromIndex = Number(e.currentTarget.dataset.qindex); + e.currentTarget.classList.add('dragging'); + e.dataTransfer.effectAllowed = 'move'; + // Firefox requires data to be set for drag to fire. + try { e.dataTransfer.setData('text/plain', String(npDragFromIndex)); } catch (_) {} +} + +function npQueueDragOver(e) { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + const row = e.currentTarget; + document.querySelectorAll('.np-queue-item.drag-over').forEach(r => r.classList.remove('drag-over')); + row.classList.add('drag-over'); +} + +function npQueueDrop(e) { + e.preventDefault(); + e.stopPropagation(); + const to = Number(e.currentTarget.dataset.qindex); + npReorderQueue(npDragFromIndex, to); +} + +function npQueueDragEnd() { + document.querySelectorAll('.np-queue-item').forEach(r => r.classList.remove('dragging', 'drag-over')); + npDragFromIndex = null; +} + +// Move a queue item, keeping npQueueIndex pointed at the SAME playing track. +function npReorderQueue(from, to) { + if (from === null || from === to || from < 0 || to < 0) return; + if (from >= npQueue.length || to >= npQueue.length) return; + const [moved] = npQueue.splice(from, 1); + npQueue.splice(to, 0, moved); + + // Recompute which index now holds the currently-playing track. + if (npQueueIndex === from) { + npQueueIndex = to; + } else if (from < npQueueIndex && to >= npQueueIndex) { + npQueueIndex -= 1; + } else if (from > npQueueIndex && to <= npQueueIndex) { + npQueueIndex += 1; + } + renderNpQueue(); + updateNpPrevNextButtons(); +} + // Up-next peek: show the track that plays after the current one. function npUpdateUpNext() { const box = document.getElementById('np-upnext'); diff --git a/webui/static/style.css b/webui/static/style.css index e9137806..87b0e54c 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -48767,6 +48767,12 @@ textarea.enhanced-meta-field-input { .np-queue-item-remove { opacity: 0; transition: opacity 0.14s, color 0.14s; } .np-queue-item:hover .np-queue-item-remove { opacity: 1; } +/* Drag-to-reorder states */ +.np-queue-item { cursor: grab; } +.np-queue-item:active { cursor: grabbing; } +.np-queue-item.dragging { opacity: 0.4; background: rgba(255,255,255,0.07) !important; } +.np-queue-item.drag-over { box-shadow: inset 0 2px 0 rgb(var(--accent-light-rgb)); } + /* Queue button in enhanced track table */ .col-queue { width: 36px;