From 4b8ec7bc9c909550c46d85486b6822983eafb9b0 Mon Sep 17 00:00:00 2001 From: tryptz Date: Wed, 22 Apr 2026 23:12:40 +0000 Subject: [PATCH 1/4] Add live spectrum analyzer overlay on the EQ graph Ports the fork's spectrum analyzer feature on top of the latest upstream. - Dedicated high-resolution AnalyserNode (fftSize 8192, ~5.9 Hz/bin at 48 kHz) tapped off the main analyser as a side-chain in js/audio-context.js. - Overlay drawn into the EQ graph canvas with 1/N-octave smoothing, pink-tilt slope compensation, and EMA time-averaging driven by a Speed preset. - In-graph control pills above the canvas (Lo range knob, Hold, Speed, FFT, Spectrum toggle). Lo/Hi knobs scroll-or-drag vertically to adjust the dB range; state persists in localStorage. - rAF loop restarts on EQ/mode changes so the overlay stays live. - Layout: grid with pills on top and canvas below on desktop; flex-column stack with flex-wrap on narrow mobile (<=600px) so the Spectrum pill no longer overlaps or wraps awkwardly over the graph. --- index.html | 73 ++++++ js/audio-context.js | 42 ++++ js/settings.js | 602 ++++++++++++++++++++++++++++++++++++++++++++ styles.css | 154 +++++++++++- 4 files changed, 870 insertions(+), 1 deletion(-) diff --git a/index.html b/index.html index e35bef71b..aa3d7828b 100644 --- a/index.html +++ b/index.html @@ -4661,6 +4661,79 @@

Speaker EQ - Room Correction

+
+ +
+
+ + + + +
diff --git a/js/audio-context.js b/js/audio-context.js index 80ac0fc48..8380f675f 100644 --- a/js/audio-context.js +++ b/js/audio-context.js @@ -511,6 +511,31 @@ class AudioContextManager { this.analyser.fftSize = 1024; this.analyser.smoothingTimeConstant = 0.7; + // High-resolution spectrum analyser for EQ graph overlay. + // Large fftSize gives ~5.9 Hz/bin at 48 kHz — critical for bass detail. + // Settings.js does its own time-averaging, so smoothing is disabled here. + this.spectrumAnalyser = this.audioContext.createAnalyser(); + try { + this.spectrumAnalyser.fftSize = 8192; + } catch { + // Older browsers cap fftSize — fall back gracefully + try { + this.spectrumAnalyser.fftSize = 4096; + } catch { + this.spectrumAnalyser.fftSize = 2048; + } + } + this.spectrumAnalyser.smoothingTimeConstant = 0; + // Widen decibel bounds to cover the overlay's Range Lo..Hi span so + // getFloatFrequencyData isn't clamped at ~[-100, -30] defaults and + // the Lo knob can reach -180 dBFS without silent saturation. + try { + this.spectrumAnalyser.minDecibels = -180; + this.spectrumAnalyser.maxDecibels = 0; + } catch { + /* some engines reject asymmetric ranges */ + } + this._createEQ(); this._createGraphicEQ(); this._createMSNodes(); @@ -609,6 +634,13 @@ class AudioContextManager { } this.analyser.connect(this.volumeNode); this.volumeNode.connect(this.audioContext.destination); + // Parallel tap for the hi-res EQ spectrum overlay (dead-end is fine + // for AnalyserNode — it only needs input to sample from). + try { + if (this.spectrumAnalyser) this.analyser.connect(this.spectrumAnalyser); + } catch { + /* ignore */ + } }; try { @@ -656,6 +688,7 @@ class AudioContextManager { this.geqFilters.forEach(safeDisconnect); safeDisconnect(this.geqOutputNode); safeDisconnect(this.analyser); + safeDisconnect(this.spectrumAnalyser); safeDisconnect(this.volumeNode); let lastNode = this.source; @@ -776,6 +809,15 @@ class AudioContextManager { return this.analyser; } + /** + * Get the dedicated high-resolution analyser for the EQ spectrum overlay. + * Returns null when no dedicated node exists so callers never mutate the + * shared visualizer analyser (which would desync its cached binCount). + */ + getSpectrumAnalyser() { + return this.spectrumAnalyser || null; + } + /** * Get the audio context */ diff --git a/js/settings.js b/js/settings.js index 9e97abc1d..550daaf7d 100644 --- a/js/settings.js +++ b/js/settings.js @@ -63,6 +63,9 @@ async function getButterchurnPresets(...args) { let _autoeqIndex = []; let _graphAbortController = null; let _graphResizeObserver = null; +// Persisted across initializeSettings() re-runs so listeners from a previous +// call can be torn down before fresh ones register. +let _spectrumListenersAbort = null; export async function initializeSettings(scrobbler, player, api, ui) { // Restore last active settings tab @@ -2027,6 +2030,323 @@ export async function initializeSettings(scrobbler, player, api, ui) { }); }; + // Spectrum overlay state (themed live analyser tinted by |EQ gain|) + let spectrumOverlayEnabled = false; + try { + spectrumOverlayEnabled = localStorage.getItem('autoeq-spectrum-overlay') === '1'; + } catch { + /* ignore */ + } + let _spectrumRafId = null; + let _spectrumData = null; + let _spectrumEma = null; + let _spectrumLastTs = 0; + // Display range after slope compensation. Hi is hard-fixed at -15 dBFS + // (never user-adjustable, never restored from storage). Only Lo is + // persisted so users can tune their display floor. + const spectrumRangeHi = -15; + let spectrumRangeLo = -103; + const SPECTRUM_RANGE_LO_MIN = -180; + const SPECTRUM_RANGE_LO_MAX = -40; + try { + const l = parseFloat(localStorage.getItem('autoeq-spectrum-range-lo')); + if (Number.isFinite(l)) spectrumRangeLo = Math.max(SPECTRUM_RANGE_LO_MIN, Math.min(SPECTRUM_RANGE_LO_MAX, l)); + } catch { + /* ignore */ + } + // SPAN-style display: pink-tilt compensation so a flat mix reads flat + const SPECTRUM_SLOPE_DB_PER_OCT = 4.0; + const SPECTRUM_SLOPE_PIVOT_HZ = 1000; + const SPECTRUM_OCTAVE_SMOOTH = 1 / 48; // minimal — keep FFT detail + // Mutable speed / FFT presets (configurable via in-graph pills) + const SPECTRUM_SPEED_PRESETS = { + Fast: 60, + Med: 110, + Slow: 260, + }; + const SPECTRUM_FFT_PRESETS = { + '2K': 2048, + '4K': 4096, + '8K': 8192, + '16K': 16384, + }; + let spectrumSpeedKey = 'Med'; + let spectrumFftKey = '8K'; + try { + const savedSpeed = localStorage.getItem('autoeq-spectrum-speed'); + if (savedSpeed && SPECTRUM_SPEED_PRESETS[savedSpeed]) spectrumSpeedKey = savedSpeed; + const savedFft = localStorage.getItem('autoeq-spectrum-fft'); + if (savedFft && SPECTRUM_FFT_PRESETS[savedFft]) spectrumFftKey = savedFft; + } catch { + /* ignore */ + } + let spectrumTimeAvgMs = SPECTRUM_SPEED_PRESETS[spectrumSpeedKey]; + let spectrumFrozen = false; + + const shouldAnimateSpectrum = () => + spectrumOverlayEnabled && + !spectrumFrozen && + equalizerSettings.isEnabled() && + currentMode !== 'legacy' && + eqContainer?.offsetParent !== null; + + const startSpectrumLoop = () => { + if (_spectrumRafId) return; + const tick = () => { + if (!shouldAnimateSpectrum()) { + _spectrumRafId = null; + scheduleDrawAutoEQGraph(); + return; + } + _spectrumRafId = requestAnimationFrame(tick); + scheduleDrawAutoEQGraph(); + }; + _spectrumRafId = requestAnimationFrame(tick); + }; + + const stopSpectrumLoop = () => { + if (_spectrumRafId) { + cancelAnimationFrame(_spectrumRafId); + _spectrumRafId = null; + } + scheduleDrawAutoEQGraph(); + }; + + const drawSpectrumLayer = (ctx, padLeft, padTop, w, h, sampleRate) => { + // Only use the dedicated spectrum analyser. If unavailable, bail so we + // never mutate fftSize on the shared visualizer node. + let analyser = null; + try { + analyser = audioContextManager?.getSpectrumAnalyser?.() || null; + } catch { + return; + } + if (!analyser) return; + if (!_spectrumData || _spectrumData.length !== analyser.frequencyBinCount) { + _spectrumData = new Float32Array(analyser.frequencyBinCount); + _spectrumEma = null; + } + const binCount = _spectrumData.length; + const nyquist = (analyser.context?.sampleRate || 48000) / 2; + const dbRange = Math.max(1, spectrumRangeHi - spectrumRangeLo); + + if (!_spectrumEma || _spectrumEma.length !== binCount) { + _spectrumEma = new Float32Array(binCount).fill(spectrumRangeLo); + } + + // When held, skip sampling + EMA update so _spectrumEma stays at its + // last value — render below still draws it, so the last frame is frozen. + if (!spectrumFrozen) { + analyser.getFloatFrequencyData(_spectrumData); + const nowTs = performance.now(); + const dtMs = _spectrumLastTs ? Math.max(1, Math.min(100, nowTs - _spectrumLastTs)) : 16; + _spectrumLastTs = nowTs; + const emaAlpha = 1 - Math.exp(-dtMs / spectrumTimeAvgMs); + for (let i = 0; i < binCount; i++) { + const raw = _spectrumData[i]; + const v = Number.isFinite(raw) ? raw : spectrumRangeLo; + _spectrumEma[i] = _spectrumEma[i] * (1 - emaAlpha) + v * emaAlpha; + } + } else { + _spectrumLastTs = 0; // fresh dt on resume, so EMA doesn't jump + } + + // Read themed RGB once per draw + const root = getComputedStyle(document.documentElement); + const rgbStr = (root.getPropertyValue('--highlight-rgb') || '236,72,153').trim(); + const parts = rgbStr.split(',').map((v) => parseInt(v, 10)); + const tr = Number.isFinite(parts[0]) ? parts[0] : 236; + const tg = Number.isFinite(parts[1]) ? parts[1] : 72; + const tb = Number.isFinite(parts[2]) ? parts[2] : 153; + + // Downsample columns so neighboring points are close in freq → quadratic + // curves between them produce a silky outline without aliasing artefacts. + const cols = Math.max(96, Math.min(240, Math.floor(w / 2))); + const step = w / cols; + + // 1/N-octave smoothing half-width (in octaves) + const halfOct = SPECTRUM_OCTAVE_SMOOTH / 2; + const fRatioLo = Math.pow(2, -halfOct); + const fRatioHi = Math.pow(2, halfOct); + const binHz = nyquist / binCount; + + // Returns 0..1 with 1 = spectrumRangeHi after slope compensation, + // 0 = spectrumRangeLo. Applies octave smoothing + +4 dB/oct pink tilt. + // Below ~120 Hz the FFT window is wider than the octave window, so the + // raw bins show as stair-steps — we force a minimum-3-bin average there + // to soften the squared plateaus without affecting treble detail. + const magAt = (freq) => { + const fLo = freq * fRatioLo; + const fHi = freq * fRatioHi; + let iLo = Math.floor(fLo / binHz); + let iHi = Math.ceil(fHi / binHz); + if (iHi < 1) return 0; + // Minimum 3-bin window: prevents same-bin plateaus at low freqs + if (iHi - iLo < 2) { + const center = Math.round(freq / binHz); + iLo = center - 1; + iHi = center + 1; + } + iLo = Math.max(1, iLo); + iHi = Math.min(binCount - 1, iHi); + if (iLo > iHi) iLo = iHi; + let sum = 0; + let count = 0; + for (let i = iLo; i <= iHi; i++) { + const v = _spectrumEma[i]; + if (Number.isFinite(v)) { + sum += v; + count++; + } + } + if (count === 0) return 0; + let db = sum / count; + // Slope compensation (pink-tilt): flat mixes display flat + db += SPECTRUM_SLOPE_DB_PER_OCT * Math.log2(freq / SPECTRUM_SLOPE_PIVOT_HZ); + const norm = (db - spectrumRangeLo) / dbRange; + return Math.max(0, Math.min(1, norm)); + }; + + // Precompute smoothed heights — kernel width scales with how many pixels + // represent one FFT bin at the column's frequency. At low freqs one bin + // spans many pixel columns → wider kernel flattens the stair-steps. + const ys = new Float32Array(cols + 1); + const raw = new Float32Array(cols + 1); + const freqs = new Float32Array(cols + 1); + for (let i = 0; i <= cols; i++) { + const freq = Math.pow(10, ((i * step) / w) * LOG_RANGE + LOG_MIN); + freqs[i] = freq; + raw[i] = magAt(freq); + } + // Per-column kernel radius: if neighbour columns are closer in Hz than + // one FFT bin, multiple columns alias to the same bin → wider kernel + // smooths the plateau. Treble columns span many bins → radius 1. + for (let i = 0; i <= cols; i++) { + const df = Math.max(0.1, freqs[Math.min(cols, i + 1)] - freqs[Math.max(0, i - 1)]) / 2; + const radius = Math.max(1, Math.min(6, Math.round(binHz / df / 2))); + let sum = 0; + let wsum = 0; + const twoSigmaSq = 2 * Math.max(0.7, radius / 2) ** 2; + for (let j = -radius; j <= radius; j++) { + const idx = i + j; + if (idx < 0 || idx > cols) continue; + const kw = Math.exp(-(j * j) / twoSigmaSq); + sum += raw[idx] * kw; + wsum += kw; + } + ys[i] = padTop + h - (sum / wsum) * h; + } + + // Trace the curve once via quadratic midpoints so the outline is smooth + const traceCurve = (closeToBaseline) => { + ctx.beginPath(); + if (closeToBaseline) ctx.moveTo(padLeft, padTop + h); + ctx.lineTo(padLeft, ys[0]); + for (let i = 0; i < cols; i++) { + const x0 = padLeft + i * step; + const x1 = padLeft + (i + 1) * step; + const mx = (x0 + x1) / 2; + const my = (ys[i] + ys[i + 1]) / 2; + ctx.quadraticCurveTo(x0, ys[i], mx, my); + } + ctx.lineTo(padLeft + w, ys[cols]); + if (closeToBaseline) { + ctx.lineTo(padLeft + w, padTop + h); + ctx.closePath(); + } + }; + + ctx.save(); + ctx.lineJoin = 'round'; + ctx.lineCap = 'round'; + + // Body gradient — dense at bottom, dissolving at top + const bodyGrad = ctx.createLinearGradient(0, padTop, 0, padTop + h); + bodyGrad.addColorStop(0, `rgba(${tr},${tg},${tb},0.05)`); + bodyGrad.addColorStop(0.55, `rgba(${tr},${tg},${tb},0.22)`); + bodyGrad.addColorStop(1, `rgba(${tr},${tg},${tb},0.42)`); + traceCurve(true); + ctx.fillStyle = bodyGrad; + ctx.fill(); + + // EQ-response tint — two single-color gradients (white for boost, black + // for cut) so gradient interpolation never drifts through grey and + // muddies the themed colour. Clipped to the spectrum body so the tint + // reads as internal lighting on the waveform. + const bands = getActiveBands() || []; + const anyActive = bands.some((b) => b && b.enabled && Math.abs(b.gain || 0) > 0.2); + if (anyActive) { + const stops = 48; + const TINT_DB_SCALE = 8; // soft-knee saturation point (dB) + const BOOST_MAX_ALPHA = 0.4; + const CUT_MAX_ALPHA = 0.5; + + // Sample EQ response once, split into boost / cut lanes + const boosts = new Float32Array(stops + 1); + const cuts = new Float32Array(stops + 1); + let hasBoost = false; + let hasCut = false; + for (let i = 0; i <= stops; i++) { + const t = i / stops; + const freq = Math.pow(10, t * LOG_RANGE + LOG_MIN); + let eqGain = 0; + for (const band of bands) { + if (band && band.enabled) { + eqGain += calculateBiquadResponse(freq, band, sampleRate); + } + } + // Soft knee past saturation so >10 dB still reads as "more" + const abs = Math.abs(eqGain); + const soft = abs <= TINT_DB_SCALE + ? abs / TINT_DB_SCALE + : 1 - Math.exp(-(abs - TINT_DB_SCALE) / TINT_DB_SCALE) * 0.5 + 0.5; + const n = Math.min(1, soft); + if (eqGain > 0) { + boosts[i] = n; + hasBoost = true; + } else if (eqGain < 0) { + cuts[i] = n; + hasCut = true; + } + } + + traceCurve(true); + ctx.save(); + ctx.clip(); + + if (hasBoost) { + const bg = ctx.createLinearGradient(padLeft, 0, padLeft + w, 0); + for (let i = 0; i <= stops; i++) { + const a = (boosts[i] * BOOST_MAX_ALPHA).toFixed(3); + bg.addColorStop(i / stops, `rgba(255,255,255,${a})`); + } + ctx.fillStyle = bg; + ctx.fillRect(padLeft, padTop, w, h); + } + if (hasCut) { + const cg = ctx.createLinearGradient(padLeft, 0, padLeft + w, 0); + for (let i = 0; i <= stops; i++) { + const a = (cuts[i] * CUT_MAX_ALPHA).toFixed(3); + cg.addColorStop(i / stops, `rgba(0,0,0,${a})`); + } + ctx.fillStyle = cg; + ctx.fillRect(padLeft, padTop, w, h); + } + ctx.restore(); + } + + // Soft rim glow — +2 px stroke width + traceCurve(false); + ctx.shadowColor = `rgba(${tr},${tg},${tb},0.55)`; + ctx.shadowBlur = 10; + ctx.strokeStyle = `rgba(${tr},${tg},${tb},0.28)`; + ctx.lineWidth = 3; + ctx.stroke(); + ctx.shadowBlur = 0; + + ctx.restore(); + }; + const drawAutoEQGraph = () => { if (!autoeqCanvas) return; const activeBands = getActiveBands(); @@ -2048,6 +2368,19 @@ export async function initializeSettings(scrobbler, player, api, ui) { ctx.clearRect(0, 0, rect.width, rect.height); + // Spectrum overlay layer (below grid + curves) + if (spectrumOverlayEnabled) { + const spectrumSampleRate = autoeqSampleRate ? parseInt(autoeqSampleRate.value, 10) : 48000; + drawSpectrumLayer( + ctx, + 40, // padLeft (matches below) + 10, // padTop + rect.width - 40 - 10, + rect.height - 10 - 30, + spectrumSampleRate + ); + } + // dB scale: fixed 75dB center for AutoEQ, 0dB center for Parametric const isParametricMode = currentMode === 'parametric'; const dbCenter = isParametricMode ? 0 : 75; @@ -4251,6 +4584,275 @@ export async function initializeSettings(scrobbler, player, api, ui) { }); } + // Spectrum overlay toggle + const spectrumBtn = document.getElementById('eq-spectrum-toggle'); + if (spectrumBtn) { + const shouldRunSpectrumLoop = () => { + return spectrumOverlayEnabled && !document.hidden && spectrumBtn.offsetParent !== null; + }; + const applySpectrumState = () => { + spectrumBtn.classList.toggle('active', spectrumOverlayEnabled); + spectrumBtn.setAttribute('aria-pressed', String(spectrumOverlayEnabled)); + if (shouldRunSpectrumLoop()) startSpectrumLoop(); + else stopSpectrumLoop(); + }; + const onSpectrumVisibilityChange = () => { + applySpectrumState(); + }; + // Re-evaluate when EQ master toggle flips, when mode changes, or when + // the equalizer-container becomes visible again. Without this, the rAF + // loop self-stops via shouldAnimateSpectrum() and never restarts — the + // graph then only redraws from other triggers (~2 fps from stray events). + const reevalSpectrumLoop = () => { + // defer one frame so display:none transitions / mode swaps finish + requestAnimationFrame(applySpectrumState); + }; + + // Tear down listeners from a previous initializeSettings() call so we + // don't accumulate duplicate handlers across re-inits. + if (_spectrumListenersAbort) _spectrumListenersAbort.abort(); + _spectrumListenersAbort = new AbortController(); + const sigOpts = { signal: _spectrumListenersAbort.signal }; + + document.addEventListener('visibilitychange', onSpectrumVisibilityChange, sigOpts); + if (eqToggle) eqToggle.addEventListener('change', reevalSpectrumLoop, sigOpts); + window.addEventListener('equalizer-toggle', reevalSpectrumLoop, sigOpts); + document.querySelectorAll('.autoeq-mode-btn').forEach((b) => + b.addEventListener('click', reevalSpectrumLoop, sigOpts) + ); + + applySpectrumState(); + spectrumBtn.addEventListener('click', () => { + spectrumOverlayEnabled = !spectrumOverlayEnabled; + try { + localStorage.setItem('autoeq-spectrum-overlay', spectrumOverlayEnabled ? '1' : '0'); + } catch { + /* ignore */ + } + applySpectrumState(); + }); + } + + // Range Hi / Range Lo knob pills + const attachRangeKnob = (btn, valueEl, opts) => { + if (!btn || !valueEl) return; + const { min, max, defaultValue, storageKey, get, set } = opts; + const clamp = (v) => Math.max(min, Math.min(max, v)); + + // ARIA: expose as an accessible slider + btn.setAttribute('role', 'slider'); + btn.setAttribute('aria-valuemin', String(min)); + btn.setAttribute('aria-valuemax', String(max)); + btn.setAttribute('tabindex', '0'); + + const render = () => { + const v = Math.round(get()); + valueEl.textContent = String(v); + btn.setAttribute('aria-valuenow', String(v)); + btn.setAttribute('aria-valuetext', `${v} dBFS`); + }; + const persist = () => { + try { + localStorage.setItem(storageKey, String(get())); + } catch { + /* ignore */ + } + }; + render(); + + btn.addEventListener( + 'wheel', + (e) => { + e.preventDefault(); + const delta = e.deltaY > 0 ? -1 : 1; + set(clamp(get() + delta)); + render(); + persist(); + }, + { passive: false } + ); + + btn.addEventListener('dblclick', (e) => { + e.preventDefault(); + set(defaultValue); + render(); + persist(); + }); + + // Keyboard: arrows adjust, Home/End jump to bounds, Shift for coarse + btn.addEventListener('keydown', (e) => { + const coarse = e.shiftKey ? 6 : 1; + let handled = true; + switch (e.key) { + case 'ArrowUp': + case 'ArrowRight': + set(clamp(get() + coarse)); + break; + case 'ArrowDown': + case 'ArrowLeft': + set(clamp(get() - coarse)); + break; + case 'PageUp': + set(clamp(get() + 10)); + break; + case 'PageDown': + set(clamp(get() - 10)); + break; + case 'Home': + set(min); + break; + case 'End': + set(max); + break; + case 'Enter': + case ' ': + set(defaultValue); + break; + default: + handled = false; + } + if (handled) { + e.preventDefault(); + render(); + persist(); + } + }); + + btn.addEventListener('pointerdown', (e) => { + if (e.button !== 0) return; + e.preventDefault(); + try { + btn.setPointerCapture(e.pointerId); + } catch { + /* capture may fail on synthetic events */ + } + btn.classList.add('dragging'); + const startY = e.clientY; + const startVal = get(); + const onMove = (ev) => { + const dy = startY - ev.clientY; + set(clamp(startVal + dy * 0.4)); + render(); + }; + const onUp = (ev) => { + // Guard releasePointerCapture: capture may already be lost + // (e.g. pointercancel fired before pointerup) + try { + if (btn.hasPointerCapture?.(e.pointerId)) { + btn.releasePointerCapture(e.pointerId); + } + } catch { + /* ignore */ + } + btn.classList.remove('dragging'); + btn.removeEventListener('pointermove', onMove); + btn.removeEventListener('pointerup', onUp); + btn.removeEventListener('pointercancel', onUp); + persist(); + ev.preventDefault(); + }; + btn.addEventListener('pointermove', onMove); + btn.addEventListener('pointerup', onUp); + btn.addEventListener('pointercancel', onUp); + }); + }; + + // Hi is hard-fixed — only wire the Lo knob + attachRangeKnob( + document.getElementById('eq-spectrum-range-lo'), + document.getElementById('eq-spectrum-range-lo-value'), + { + min: SPECTRUM_RANGE_LO_MIN, + max: SPECTRUM_RANGE_LO_MAX, + defaultValue: -103, + storageKey: 'autoeq-spectrum-range-lo', + get: () => spectrumRangeLo, + set: (v) => { + spectrumRangeLo = Math.min(spectrumRangeHi - 6, v); + }, + } + ); + + // Hold (pause/play) pill + const holdBtn = document.getElementById('eq-spectrum-hold'); + const holdIconEl = document.getElementById('eq-spectrum-hold-icon'); + const holdValueEl = document.getElementById('eq-spectrum-hold-value'); + const HOLD_ICON_PAUSE = + ''; + const HOLD_ICON_PLAY = ''; + if (holdBtn && holdIconEl && holdValueEl) { + const applyHold = () => { + holdBtn.classList.toggle('active', spectrumFrozen); + holdBtn.setAttribute('aria-pressed', String(spectrumFrozen)); + holdIconEl.innerHTML = spectrumFrozen ? HOLD_ICON_PLAY : HOLD_ICON_PAUSE; + holdValueEl.textContent = spectrumFrozen ? 'Held' : 'Hold'; + }; + applyHold(); + holdBtn.addEventListener('click', () => { + spectrumFrozen = !spectrumFrozen; + applyHold(); + // Re-evaluate the rAF loop: pause when freezing, resume when + // unfreezing (shouldAnimateSpectrum will gate either way). + if (spectrumFrozen) stopSpectrumLoop(); + else startSpectrumLoop(); + }); + } + + // Speed cycle pill + const speedBtn = document.getElementById('eq-spectrum-speed'); + const speedValueEl = document.getElementById('eq-spectrum-speed-value'); + if (speedBtn && speedValueEl) { + const speedKeys = Object.keys(SPECTRUM_SPEED_PRESETS); + const applySpeed = () => { + spectrumTimeAvgMs = SPECTRUM_SPEED_PRESETS[spectrumSpeedKey]; + speedValueEl.textContent = spectrumSpeedKey; + }; + applySpeed(); + speedBtn.addEventListener('click', () => { + const idx = speedKeys.indexOf(spectrumSpeedKey); + spectrumSpeedKey = speedKeys[(idx + 1) % speedKeys.length]; + try { + localStorage.setItem('autoeq-spectrum-speed', spectrumSpeedKey); + } catch { + /* ignore */ + } + applySpeed(); + }); + } + + // FFT cycle pill + const fftBtn = document.getElementById('eq-spectrum-fft'); + const fftValueEl = document.getElementById('eq-spectrum-fft-value'); + if (fftBtn && fftValueEl) { + const fftKeys = Object.keys(SPECTRUM_FFT_PRESETS); + const applyFft = () => { + fftValueEl.textContent = spectrumFftKey; + const size = SPECTRUM_FFT_PRESETS[spectrumFftKey]; + try { + const an = audioContextManager?.getSpectrumAnalyser?.(); + if (an && an.fftSize !== size) { + an.fftSize = size; + // Force buffer reallocation on the next draw + _spectrumData = null; + _spectrumEma = null; + } + } catch { + /* ignore */ + } + }; + applyFft(); + fftBtn.addEventListener('click', () => { + const idx = fftKeys.indexOf(spectrumFftKey); + spectrumFftKey = fftKeys[(idx + 1) % fftKeys.length]; + try { + localStorage.setItem('autoeq-spectrum-fft', spectrumFftKey); + } catch { + /* ignore */ + } + applyFft(); + }); + } + // ======================================== // Redraw graph when target/settings change // ======================================== diff --git a/styles.css b/styles.css index 8f2f61e49..30eeffa0d 100644 --- a/styles.css +++ b/styles.css @@ -153,6 +153,7 @@ --ring: #f5f5f5; --highlight: #f5f5f5; --highlight-rgb: 245, 245, 245; + --highlight-foreground: #0a0a0a; --active-highlight: var(--highlight); --explicit-badge: #f5f5f5; } @@ -175,6 +176,7 @@ --ring: #3b82f6; --highlight: #3b82f6; --highlight-rgb: 59, 130, 246; + --highlight-foreground: #ffffff; --active-highlight: #3b82f6; --explicit-badge: #750a0a; } @@ -197,6 +199,7 @@ --ring: #06b6d4; --highlight: #06b6d4; --highlight-rgb: 6, 182, 212; + --highlight-foreground: #0c1821; --active-highlight: #06b6d4; --explicit-badge: #f43f5e; } @@ -219,6 +222,7 @@ --ring: #a855f7; --highlight: #a855f7; --highlight-rgb: 168, 85, 247; + --highlight-foreground: #ffffff; --active-highlight: #a855f7; --explicit-badge: #ec4899; } @@ -241,6 +245,7 @@ --ring: #22c55e; --highlight: #22c55e; --highlight-rgb: 34, 197, 94; + --highlight-foreground: #0a1409; --active-highlight: #22c55e; --explicit-badge: #f59e0b; } @@ -263,6 +268,7 @@ --ring: #89b4fa; --highlight: #89b4fa; --highlight-rgb: 180, 190, 254; + --highlight-foreground: #1e1e2e; --active-highlight: #b4befe; --explicit-badge: #f9e2af; } @@ -285,6 +291,7 @@ --ring: #8aadf4; --highlight: #8aadf4; --highlight-rgb: 183, 189, 248; + --highlight-foreground: #24273a; --active-highlight: #b7bdf8; --explicit-badge: #eed49f; } @@ -307,6 +314,7 @@ --ring: #8caaee; --highlight: #8caaee; --highlight-rgb: 186, 187, 241; + --highlight-foreground: #303446; --active-highlight: #babbf1; --explicit-badge: #e5c890; } @@ -329,6 +337,7 @@ --ring: #fdfdfd; --highlight: #1e66f5; --highlight-rgb: 114, 135, 253; + --highlight-foreground: #eff1f5; --active-highlight: #7287fd; --explicit-badge: #df8e1d; } @@ -351,6 +360,7 @@ --ring: #1a1a1a; --highlight: #1a1a1a; --highlight-rgb: 26, 26, 26; + --highlight-foreground: #f5f5f5; --active-highlight: var(--highlight); --explicit-badge: #1a1a1a; --cover-filter: blur(50px) brightness(1.6) opacity(0.35); @@ -8328,6 +8338,98 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { background: var(--highlight); } +.eq-spectrum-controls { + grid-row: 1; + grid-column: 3; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + flex-wrap: wrap; + justify-content: flex-end; + z-index: 2; +} + +.eq-spectrum-range { + grid-row: 1; + grid-column: 1; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + z-index: 2; +} + +.eq-spectrum-knob { + cursor: ns-resize; + touch-action: none; +} + +.eq-spectrum-pill, +.eq-spectrum-toggle-in-graph { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 8px 4px 6px; + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; + border: 1px solid rgb(255 255 255 / 18%); + border-radius: 999px; + background: rgb(0 0 0 / 45%); + color: rgb(255 255 255 / 70%); + cursor: pointer; + user-select: none; + backdrop-filter: blur(4px); + transition: + color var(--transition-fast), + background var(--transition-fast), + border-color var(--transition-fast); +} + +.eq-spectrum-pill .pill-label { + opacity: 0.55; + font-weight: 500; +} + +.eq-spectrum-pill .pill-value { + color: #fff; + font-variant-numeric: tabular-nums; +} + +/* stylelint-disable-next-line no-descending-specificity */ +.eq-spectrum-toggle-in-graph svg { + flex-shrink: 0; +} + +.eq-spectrum-pill:hover, +.eq-spectrum-toggle-in-graph:hover { + color: #fff; + border-color: rgb(255 255 255 / 35%); + background: rgb(0 0 0 / 60%); +} + +.eq-spectrum-toggle-in-graph.active, +.eq-spectrum-pill.active { + color: var(--highlight-foreground, var(--primary-foreground)); + background: var(--highlight); + border-color: var(--highlight); + box-shadow: 0 0 0 1px rgb(var(--highlight-rgb), 0.3); +} + +.eq-spectrum-pill.active .pill-value, +.eq-spectrum-pill.active .pill-label { + color: inherit; + opacity: 1; +} + +.eq-spectrum-knob.dragging { + color: var(--primary-foreground); + background: rgb(var(--highlight-rgb), 0.35); + border-color: rgb(var(--highlight-rgb), 0.6); +} + .eq-howto-panel { border: 1px solid var(--border); border-radius: var(--radius); @@ -8484,6 +8586,11 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { position: relative; width: 100%; height: 300px; + display: grid; + grid-template-columns: auto 1fr auto; + grid-template-rows: auto 1fr; + column-gap: 8px; + align-items: start; background: color-mix(in srgb, var(--background) 25%, #111); border: 1px solid var(--border); border-radius: var(--radius); @@ -8492,8 +8599,11 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { .autoeq-response-canvas { display: block; + grid-row: 2; + grid-column: 1 / -1; width: 100%; height: 100%; + min-height: 0; cursor: crosshair; } @@ -9596,9 +9706,51 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { } } +@media (max-width: 600px) { + .autoeq-graph-wrapper { + display: flex; + flex-direction: column; + height: auto; + min-height: 260px; + overflow: visible; + } + + .autoeq-graph-wrapper .autoeq-response-canvas { + order: 3; + flex: 1 1 auto; + width: 100%; + height: auto; + min-height: 180px; + } + + .eq-spectrum-range { + order: 1; + padding: 6px 8px 0; + align-self: flex-start; + } + + .eq-spectrum-controls { + order: 2; + flex-wrap: wrap; + justify-content: flex-end; + padding: 4px 8px 6px; + width: 100%; + } + + .eq-spectrum-pill, + .eq-spectrum-toggle-in-graph { + padding: 4px 8px; + font-size: 0.68rem; + } +} + @media (max-width: 480px) { .autoeq-graph-wrapper { - height: 180px; + min-height: 240px; + } + + .autoeq-graph-wrapper .autoeq-response-canvas { + min-height: 160px; } .autoeq-graph-header { From cd23db02fe5f587bd52a6b973d5f5111596b4375 Mon Sep 17 00:00:00 2001 From: tryptz Date: Sun, 3 May 2026 21:46:02 +0000 Subject: [PATCH 2/4] fix: regenerate bun.lock to match package.json --- bun.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bun.lock b/bun.lock index 1e31d77ec..318b02a88 100644 --- a/bun.lock +++ b/bun.lock @@ -19,7 +19,7 @@ "@svta/common-media-library": "^0.18.1", "@types/wicg-file-system-access": "^2023.10.7", "@typescript-eslint/eslint-plugin": "^8.57.2", - "@uimaxbai/am-lyrics": "^1.2.8", + "@uimaxbai/am-lyrics": "^1.4.1", "@vitest/web-worker": "^4.1.2", "appwrite": "^23.0.0", "butterchurn": "^2.6.7", @@ -678,7 +678,7 @@ "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.58.0", "", { "dependencies": { "@typescript-eslint/types": "8.58.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ=="], - "@uimaxbai/am-lyrics": ["@uimaxbai/am-lyrics@1.2.8", "", { "dependencies": { "@babel/runtime": "^7.27.6", "lit": "^3.1.4" }, "peerDependencies": { "@lit/react": "^1.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@lit/react", "react"] }, "sha512-aR8kxqIYcVlsMCH6bbH8ANG+bN/2OAw66ZFjYD1a25hkMTyxtULWgWwAZlUfreP9V47bFvNgXIKvOqhO5JFpeg=="], + "@uimaxbai/am-lyrics": ["@uimaxbai/am-lyrics@1.4.1", "", { "dependencies": { "@babel/runtime": "^7.27.6", "lit": "^3.1.4" }, "peerDependencies": { "@lit/react": "^1.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@lit/react", "react"] }, "sha512-3oEAJzDhC7WqeAIDodEj+ZujtIogCXGtmCrl2wAhuQVSQP2iMjR3OL7ZWx/boC5BdIMfHqV8ucEFoeM1G9Ye8w=="], "@vitest/browser": ["@vitest/browser@4.1.2", "", { "dependencies": { "@blazediff/core": "1.9.1", "@vitest/mocker": "4.1.2", "@vitest/utils": "4.1.2", "magic-string": "^0.30.21", "pngjs": "^7.0.0", "sirv": "^3.0.2", "tinyrainbow": "^3.1.0", "ws": "^8.19.0" }, "peerDependencies": { "vitest": "4.1.2" } }, "sha512-CwdIf90LNf1Zitgqy63ciMAzmyb4oIGs8WZ40VGYrWkssQKeEKr32EzO8MKUrDPPcPVHFI9oQ5ni2Hp24NaNRQ=="], From 432d69de32fbf9242fc055f25ce6cd1ad1c79073 Mon Sep 17 00:00:00 2001 From: tryptz <216453278+tryptz@users.noreply.github.com> Date: Sun, 3 May 2026 21:46:49 +0000 Subject: [PATCH 3/4] style: auto-fix linting issues --- INSTANCES.md | 1 - README.md | 1 - extension/README.md | 3 ++- functions/album/[id].js | 4 +--- functions/artist/[id].js | 4 +--- functions/playlist/[id].js | 4 +--- functions/track/[id].js | 4 +--- index.html | 5 +++-- js/HiFi.test.ts | 6 +++++- js/HiFi.ts | 8 ++++---- js/api.js | 4 +--- js/db.js | 4 +++- js/download-utils.ts | 2 +- js/settings.js | 13 +++++++------ js/storage.js | 12 +++--------- js/ui.js | 4 +++- styles.css | 4 ++-- tsconfig.json | 6 +++--- 18 files changed, 41 insertions(+), 48 deletions(-) diff --git a/INSTANCES.md b/INSTANCES.md index 8c301c662..6ac0cbd1e 100644 --- a/INSTANCES.md +++ b/INSTANCES.md @@ -1,7 +1,6 @@ > [!important] > April 30th, 2026: this file is currently outdated, as we have switched away from hifi-api (kinda), however were lowk too lazy right now, though it will be updated soon. - # Monochrome Instances This document lists public instances of Monochrome that you can use. Instances are community-hosted versions of Monochrome that provide access to the application. diff --git a/README.md b/README.md index dee615b54..f7bd9ba9a 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,3 @@ -

Monochrome Logo diff --git a/extension/README.md b/extension/README.md index 880ead345..0ba493d41 100644 --- a/extension/README.md +++ b/extension/README.md @@ -3,7 +3,8 @@ While the website works without the extension with the use of proxies, it is recommended to install it to prevent various annoying issues. The website works best with the extension on. ## What it does -It makes your `requestHeaders` appear to come from Tidal, which can result in fewer blocked requests. + +It makes your `requestHeaders` appear to come from Tidal, which can result in fewer blocked requests. ## Installation diff --git a/functions/album/[id].js b/functions/album/[id].js index c82518107..9e988e7a6 100644 --- a/functions/album/[id].js +++ b/functions/album/[id].js @@ -48,9 +48,7 @@ class TidalAPI { class ServerAPI { constructor() { - this.INSTANCES_URLS = [ - 'https://tidal-uptime.geeked.wtf', - ]; + this.INSTANCES_URLS = ['https://tidal-uptime.geeked.wtf']; this.apiInstances = null; } diff --git a/functions/artist/[id].js b/functions/artist/[id].js index 28ffd11fa..b233c2cc6 100644 --- a/functions/artist/[id].js +++ b/functions/artist/[id].js @@ -48,9 +48,7 @@ class TidalAPI { class ServerAPI { constructor() { - this.INSTANCES_URLS = [ - 'https://tidal-uptime.geeked.wtf', - ]; + this.INSTANCES_URLS = ['https://tidal-uptime.geeked.wtf']; this.apiInstances = null; } diff --git a/functions/playlist/[id].js b/functions/playlist/[id].js index 79334ae55..07c4bbfbf 100644 --- a/functions/playlist/[id].js +++ b/functions/playlist/[id].js @@ -48,9 +48,7 @@ class TidalAPI { class ServerAPI { constructor() { - this.INSTANCES_URLS = [ - 'https://tidal-uptime.geeked.wtf', - ]; + this.INSTANCES_URLS = ['https://tidal-uptime.geeked.wtf']; this.apiInstances = null; } diff --git a/functions/track/[id].js b/functions/track/[id].js index 527bf8e7d..50c156c6d 100644 --- a/functions/track/[id].js +++ b/functions/track/[id].js @@ -70,9 +70,7 @@ class TidalAPI { class ServerAPI { constructor() { - this.INSTANCES_URLS = [ - 'https://tidal-uptime.geeked.wtf', - ]; + this.INSTANCES_URLS = ['https://tidal-uptime.geeked.wtf']; this.apiInstances = null; } diff --git a/index.html b/index.html index 8c467c6e9..64ec3e4b0 100644 --- a/index.html +++ b/index.html @@ -1745,7 +1745,8 @@

Pinned

+
diff --git a/js/HiFi.test.ts b/js/HiFi.test.ts index 4231a83ec..d136493e0 100644 --- a/js/HiFi.test.ts +++ b/js/HiFi.test.ts @@ -129,7 +129,11 @@ test('Fetch artist info', async () => { await checkRoute( `/artist/?id=${ARTIST_ID}`, () => instance.getArtist(ARTIST_ID), - async (info: { cover: string; tracks: Array<{ duration?: number }>; albums: { items: Array<{ duration?: number }> } }) => { + async (info: { + cover: string; + tracks: Array<{ duration?: number }>; + albums: { items: Array<{ duration?: number }> }; + }) => { expect(info).toHaveProperty('cover'); expect(info.cover).not.toBeUndefined(); diff --git a/js/HiFi.ts b/js/HiFi.ts index 03cd45190..bdb413d86 100644 --- a/js/HiFi.ts +++ b/js/HiFi.ts @@ -1789,7 +1789,7 @@ class HiFiClient { if (!iso || typeof iso !== 'string') return undefined; const m = iso.match(/^PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?$/); if (!m || (!m[1] && !m[2] && !m[3])) return undefined; - return (parseInt(m[1] || '0', 10) * 3600) + (parseInt(m[2] || '0', 10) * 60) + parseInt(m[3] || '0', 10); + return parseInt(m[1] || '0', 10) * 3600 + parseInt(m[2] || '0', 10) * 60 + parseInt(m[3] || '0', 10); }; const albums: any[] = []; @@ -1956,11 +1956,11 @@ class HiFiClient { } const bioRelData = payload?.data?.relationships?.biography?.data; - const bioRef = (Array.isArray(bioRelData) ? bioRelData[0] : bioRelData) as JsonApiRef | undefined; + const bioRef = Array.isArray(bioRelData) ? bioRelData[0] : bioRelData; const bioItem = bioRef ? (includedMap.get(`${bioRef.type}:${bioRef.id}`) ?? - includedMap.get(`biographies:${bioRef.id}`) ?? - includedMap.get(`biography:${bioRef.id}`)) + includedMap.get(`biographies:${bioRef.id}`) ?? + includedMap.get(`biography:${bioRef.id}`)) : undefined; const data: ArtistBiography = { diff --git a/js/api.js b/js/api.js index 04f4b8bb6..f321aa6c9 100644 --- a/js/api.js +++ b/js/api.js @@ -127,9 +127,7 @@ export class LosslessAPI { ? `${baseUrl}${relativePath.substring(1)}` : `${baseUrl}${relativePath}`; - const url = isTidal - ? wrapTidalUrl(targetUrl) - : targetUrl; + const url = isTidal ? wrapTidalUrl(targetUrl) : targetUrl; try { const response = await fetch(url, { signal: options.signal }); diff --git a/js/db.js b/js/db.js index 5c1ac0b94..43db14acb 100644 --- a/js/db.js +++ b/js/db.js @@ -469,7 +469,9 @@ export class MusicDatabase { ].every((arr) => !arr || (Array.isArray(arr) ? arr.length === 0 : Object.keys(arr).length === 0)); if (allEmpty) { - console.warn('[importData] Aborting: clear=true but all import data is empty. Existing data preserved.'); + console.warn( + '[importData] Aborting: clear=true but all import data is empty. Existing data preserved.' + ); return false; } } diff --git a/js/download-utils.ts b/js/download-utils.ts index f77746e74..8254244c8 100644 --- a/js/download-utils.ts +++ b/js/download-utils.ts @@ -118,7 +118,7 @@ export async function applyAudioPostProcessing( // Transcode to AAC to match expected lossy output. if (sourceIsLossless && !statedLossless && !isCustomFormat(quality)) { try { - const bitrateMap: Record = {HIGH: '320k', FFMPEG_AAC_256: '256k', LOW: '96k' }; + const bitrateMap: Record = { HIGH: '320k', FFMPEG_AAC_256: '256k', LOW: '96k' }; const bitrate = bitrateMap[quality] || '256k'; blob = await ffmpeg(blob, { args: ['-map_metadata', '-1', '-c:a', 'aac', '-b:a', bitrate], diff --git a/js/settings.js b/js/settings.js index 45f6ba6dc..b274a010c 100644 --- a/js/settings.js +++ b/js/settings.js @@ -2289,9 +2289,10 @@ export async function initializeSettings(scrobbler, player, api, ui) { } // Soft knee past saturation so >10 dB still reads as "more" const abs = Math.abs(eqGain); - const soft = abs <= TINT_DB_SCALE - ? abs / TINT_DB_SCALE - : 1 - Math.exp(-(abs - TINT_DB_SCALE) / TINT_DB_SCALE) * 0.5 + 0.5; + const soft = + abs <= TINT_DB_SCALE + ? abs / TINT_DB_SCALE + : 1 - Math.exp(-(abs - TINT_DB_SCALE) / TINT_DB_SCALE) * 0.5 + 0.5; const n = Math.min(1, soft); if (eqGain > 0) { boosts[i] = n; @@ -4609,9 +4610,9 @@ export async function initializeSettings(scrobbler, player, api, ui) { document.addEventListener('visibilitychange', onSpectrumVisibilityChange, sigOpts); if (eqToggle) eqToggle.addEventListener('change', reevalSpectrumLoop, sigOpts); window.addEventListener('equalizer-toggle', reevalSpectrumLoop, sigOpts); - document.querySelectorAll('.autoeq-mode-btn').forEach((b) => - b.addEventListener('click', reevalSpectrumLoop, sigOpts) - ); + document + .querySelectorAll('.autoeq-mode-btn') + .forEach((b) => b.addEventListener('click', reevalSpectrumLoop, sigOpts)); applySpectrumState(); spectrumBtn.addEventListener('click', () => { diff --git a/js/storage.js b/js/storage.js index 3b0dce809..c8d35731b 100644 --- a/js/storage.js +++ b/js/storage.js @@ -4,9 +4,7 @@ import { SVG_RIGHT_ARROW } from './icons'; export const apiSettings = { STORAGE_KEY: 'monochrome-api-instances-v9', - INSTANCES_URLS: [ - 'https://tidal-uptime.geeked.wtf', - ], + INSTANCES_URLS: ['https://tidal-uptime.geeked.wtf'], defaultInstances: { api: [], streaming: [], qobuz: [] }, userInstances: null, instancesLoaded: false, @@ -98,9 +96,7 @@ export const apiSettings = { { url: 'https://hund.qqdl.site', version: '2.6' }, { url: 'https://wolf.qqdl.site', version: '2.6' }, ], - qobuz: [ - { url: 'https://qobuz.kennyy.com.br', version: '1.0' }, - ], + qobuz: [{ url: 'https://qobuz.kennyy.com.br', version: '1.0' }], }; this.instancesLoaded = true; this._loadPromise = null; @@ -130,9 +126,7 @@ export const apiSettings = { // Ensure default Qobuz instance is always available if (groupedInstances.qobuz.length === 0) { - groupedInstances.qobuz = [ - { url: 'https://qobuz.kennyy.com.br', version: '1.0' }, - ]; + groupedInstances.qobuz = [{ url: 'https://qobuz.kennyy.com.br', version: '1.0' }]; } this.defaultInstances = groupedInstances; diff --git a/js/ui.js b/js/ui.js index 074043324..6f41fdbd7 100644 --- a/js/ui.js +++ b/js/ui.js @@ -6097,7 +6097,9 @@ export class UIRenderer { container.innerHTML = renderGroup(apiInstances, 'api') + - (streamingInstances && streamingInstances.length > 0 ? renderGroup(streamingInstances, 'streaming') : '') + + (streamingInstances && streamingInstances.length > 0 + ? renderGroup(streamingInstances, 'streaming') + : '') + renderGroup(qobuzInstances, 'qobuz'); const stats = this.api.getCacheStats(); diff --git a/styles.css b/styles.css index eb71d899e..3312a9ed5 100644 --- a/styles.css +++ b/styles.css @@ -187,7 +187,7 @@ --ring: #3b82f6; --highlight: #3b82f6; --highlight-rgb: 59, 130, 246; - --highlight-foreground: #ffffff; + --highlight-foreground: #fff; --active-highlight: #3b82f6; --explicit-badge: #750a0a; } @@ -233,7 +233,7 @@ --ring: #a855f7; --highlight: #a855f7; --highlight-rgb: 168, 85, 247; - --highlight-foreground: #ffffff; + --highlight-foreground: #fff; --active-highlight: #a855f7; --explicit-badge: #ec4899; } diff --git a/tsconfig.json b/tsconfig.json index 2491a5345..9e1ee2d3d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,7 +7,7 @@ "types": ["vite/client", "node", "@types/wicg-file-system-access"], "baseUrl": ".", "paths": { - "!/*": ["node_modules/*"], + "!/*": ["node_modules/*"] }, "allowJs": true, "checkJs": false, @@ -16,8 +16,8 @@ "verbatimModuleSyntax": true, "ignoreDeprecations": "5.0", "skipLibCheck": true, - "noEmit": true, + "noEmit": true }, "include": ["**/*.ts", "*.ts", "**/*.js", "*.js"], - "exclude": ["**/node_modules/*"], + "exclude": ["**/node_modules/*"] } From 773f787ecbe1404a20c800102789cc6c46a3f66d Mon Sep 17 00:00:00 2001 From: tryptz <216453278+tryptz@users.noreply.github.com> Date: Wed, 13 May 2026 08:01:03 +0000 Subject: [PATCH 4/4] fix: regenerate bun.lock to match package.json after upstream merge am-lyrics bumped to ^1.5.2 in upstream; refresh lockfile so bun install --frozen-lockfile in CI lint workflow passes. --- bun.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bun.lock b/bun.lock index 318b02a88..989cbf638 100644 --- a/bun.lock +++ b/bun.lock @@ -19,7 +19,7 @@ "@svta/common-media-library": "^0.18.1", "@types/wicg-file-system-access": "^2023.10.7", "@typescript-eslint/eslint-plugin": "^8.57.2", - "@uimaxbai/am-lyrics": "^1.4.1", + "@uimaxbai/am-lyrics": "^1.5.2", "@vitest/web-worker": "^4.1.2", "appwrite": "^23.0.0", "butterchurn": "^2.6.7", @@ -678,7 +678,7 @@ "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.58.0", "", { "dependencies": { "@typescript-eslint/types": "8.58.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ=="], - "@uimaxbai/am-lyrics": ["@uimaxbai/am-lyrics@1.4.1", "", { "dependencies": { "@babel/runtime": "^7.27.6", "lit": "^3.1.4" }, "peerDependencies": { "@lit/react": "^1.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@lit/react", "react"] }, "sha512-3oEAJzDhC7WqeAIDodEj+ZujtIogCXGtmCrl2wAhuQVSQP2iMjR3OL7ZWx/boC5BdIMfHqV8ucEFoeM1G9Ye8w=="], + "@uimaxbai/am-lyrics": ["@uimaxbai/am-lyrics@1.5.2", "", { "dependencies": { "@babel/runtime": "^7.27.6", "lit": "^3.1.4" }, "peerDependencies": { "@lit/react": "^1.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@lit/react", "react"] }, "sha512-nbBgGFTb3rop+rj3d21rZCIJQEjic70MwLONFmpb2GIDjVWln9hkbotvnyDBES1GpRt54TbIyIGp2NGuO/J0Mw=="], "@vitest/browser": ["@vitest/browser@4.1.2", "", { "dependencies": { "@blazediff/core": "1.9.1", "@vitest/mocker": "4.1.2", "@vitest/utils": "4.1.2", "magic-string": "^0.30.21", "pngjs": "^7.0.0", "sirv": "^3.0.2", "tinyrainbow": "^3.1.0", "ws": "^8.19.0" }, "peerDependencies": { "vitest": "4.1.2" } }, "sha512-CwdIf90LNf1Zitgqy63ciMAzmyb4oIGs8WZ40VGYrWkssQKeEKr32EzO8MKUrDPPcPVHFI9oQ5ni2Hp24NaNRQ=="],