diff --git a/media_server/services/audio_analyzer.py b/media_server/services/audio_analyzer.py index f96f019..321abc6 100644 --- a/media_server/services/audio_analyzer.py +++ b/media_server/services/audio_analyzer.py @@ -1,6 +1,7 @@ """Audio spectrum analyzer service using system loopback capture.""" import logging +import math import platform import threading import time @@ -71,6 +72,14 @@ class AudioAnalyzer: self._lifecycle_lock = threading.Lock() self._data: dict | None = None self._current_device_name: str | None = None + # Generation counter — bumped each time _data is refreshed. + # Lets the broadcast loop dedupe without comparing dict identity + # (which is fragile because we always allocate a new dict). + self._data_seq = 0 + # Threading.Event signaled when new frame data is available. + # The broadcast loop awaits this instead of polling on a timer, + # so it wakes up exactly once per produced frame. + self._data_event = threading.Event() # Slow AGC envelope so the spectrum reflects real dynamics # instead of being renormalized to peak=1.0 every frame. # A loud transient (e.g. notification beep) lifts the reference @@ -128,17 +137,30 @@ class AudioAnalyzer: """Stop audio capture and cleanup.""" with self._lifecycle_lock: self._running = False + # Wake any waiter so it can observe _running and exit cleanly. + self._data_event.set() if self._thread: self._thread.join(timeout=3.0) self._thread = None with self._lock: self._data = None + self._data_event.clear() def get_frequency_data(self) -> dict | None: """Return latest frequency data (thread-safe). None if not running.""" with self._lock: return self._data + def get_frequency_data_versioned(self) -> tuple[dict | None, int]: + """Return (data, seq) so callers can dedupe without identity tricks.""" + with self._lock: + return self._data, self._data_seq + + @property + def data_event(self) -> threading.Event: + """Event signaled when a fresh frame is ready. Caller must clear().""" + return self._data_event + @staticmethod def list_loopback_devices() -> list[dict[str, str]]: """List all available loopback audio devices.""" @@ -250,12 +272,24 @@ class AudioAnalyzer: return interval = 1.0 / self.target_fps - window = np.hanning(self.chunk_size) + # Float32 window — matches soundcard's typical buffer dtype and + # halves FFT memory traffic vs. the default float64. + window = np.hanning(self.chunk_size).astype(np.float32) # Pre-compute bin edge pairs for vectorized grouping edges = self._bin_edges bin_starts = np.array([edges[i] for i in range(self.num_bins)], dtype=np.intp) bin_ends = np.array([max(edges[i + 1], edges[i] + 1) for i in range(self.num_bins)], dtype=np.intp) + # Counts are constant — compute once. + bin_counts = (bin_ends - bin_starts).astype(np.float32) + + # Pre-allocate working buffers so the per-frame allocator churn + # on the capture thread (which runs at target_fps Hz, hours on + # end) drops to zero copies for these arrays. + fft_size = self.chunk_size // 2 + 1 + windowed = np.empty(self.chunk_size, dtype=np.float32) + cumsum = np.empty(fft_size + 1, dtype=np.float32) + cumsum[0] = 0.0 try: with device.recorder( @@ -284,21 +318,23 @@ class AudioAnalyzer: time.sleep(interval) continue - # Apply window and compute FFT - windowed = mono[:self.chunk_size] * window + # Apply window in-place into the pre-allocated buffer. + np.multiply(mono[:self.chunk_size], window, out=windowed) fft_mag = np.abs(np.fft.rfft(windowed)) - # Group into logarithmic bins (vectorized via cumsum) - cumsum = np.concatenate(([0.0], np.cumsum(fft_mag))) - counts = bin_ends - bin_starts - bins = (cumsum[bin_ends] - cumsum[bin_starts]) / counts + # Group into logarithmic bins (vectorized via cumsum). + # Write into the pre-allocated [1:] slice so cumsum[0] + # stays 0.0 and we never allocate a new array. + np.cumsum(fft_mag, out=cumsum[1:]) + bins = (cumsum[bin_ends] - cumsum[bin_starts]) / bin_counts - # True loudness from time-domain RMS, mapped via dB - # so the VU needle reflects actual program level — not - # the per-frame-normalized spectrum. - rms = float(np.sqrt(np.mean(mono.astype(np.float64) ** 2))) - if rms > 1e-6: - db = 20.0 * np.log10(rms) + # True loudness from time-domain RMS via single BLAS + # dot — avoids astype() and ** allocations. + mono32 = mono if mono.dtype == np.float32 else mono.astype(np.float32, copy=False) + energy = float(np.dot(mono32, mono32)) + if energy > 1e-12: + rms = (energy / mono32.size) ** 0.5 + db = 20.0 * math.log10(rms) # Map -60 dB..-6 dB to 0..1 (typical music range) level = max(0.0, min(1.0, (db + 60.0) / 54.0)) else: @@ -314,18 +350,33 @@ class AudioAnalyzer: else: self._spectrum_ref += (current_peak - self._spectrum_ref) * 0.005 ref = max(self._spectrum_ref, 1e-4) - bins = np.clip(bins / ref, 0.0, 1.5) + np.divide(bins, ref, out=bins) + np.clip(bins, 0.0, 1.5, out=bins) # Bass energy: average of first 4 bins (~20-200Hz) bass = float(bins[:4].mean()) if self.num_bins >= 4 else 0.0 - # Round for compact JSON - frequencies = np.round(bins, 3).tolist() - bass = round(bass, 3) - level = round(level, 3) + # Quantize to 0..1000 ints — same wire fidelity as + # 3-decimal floats but smaller GC churn on both ends + # (frontend smooths anyway, so quantization is + # invisible). JSON encodes ints faster than floats. + frequencies = (bins * 1000.0).astype(np.int16).tolist() + bass_i = int(bass * 1000.0) + level_i = int(level * 1000.0) + new_data = { + "frequencies": frequencies, + "bass": bass_i, + "level": level_i, + # Wire-format flag: clients that see this know + # values are 0..1000 ints, not 0..1 floats. + "scale": 1000, + } with self._lock: - self._data = {"frequencies": frequencies, "bass": bass, "level": level} + self._data = new_data + self._data_seq += 1 + # Wake any broadcast loop waiting on fresh data. + self._data_event.set() # Throttle to target FPS elapsed = time.monotonic() - t0 diff --git a/media_server/services/websocket_manager.py b/media_server/services/websocket_manager.py index 3e9518b..5f2d57e 100644 --- a/media_server/services/websocket_manager.py +++ b/media_server/services/websocket_manager.py @@ -161,26 +161,48 @@ class ConnectionManager: self._audio_task = None async def _audio_broadcast_loop(self) -> None: - """Background loop: read frequency data from analyzer and broadcast to subscribers.""" - from ..config import settings - interval = 1.0 / settings.visualizer_fps + """Background loop: read frequency data from analyzer and broadcast to subscribers. - _last_data = None + Event-driven: blocks on the analyzer's data_event so it wakes up + exactly once per produced frame, instead of polling on a timer. + Backstop sleep applies when capture is idle / has no subscribers. + """ + from ..config import settings + idle_interval = 1.0 / max(1, settings.visualizer_fps) + # Bounded wait so we still notice subscribe/unsubscribe transitions. + wake_timeout = max(0.05, idle_interval) + loop = asyncio.get_event_loop() + + last_seq = -1 while True: try: async with self._lock: subscribers = list(self._visualizer_subscribers) - if not subscribers or not self._audio_analyzer or not self._audio_analyzer.running: - await asyncio.sleep(interval) + analyzer = self._audio_analyzer + if not subscribers or not analyzer or not analyzer.running: + await asyncio.sleep(idle_interval) continue - data = self._audio_analyzer.get_frequency_data() - if data is None or data is _last_data: - await asyncio.sleep(interval) + # Wait off-loop for a fresh frame. The capture thread sets + # data_event after each FFT update; we clear it before the + # next wait so we never burn a wake on stale data. + ev = analyzer.data_event + + def _wait() -> bool: + return ev.wait(wake_timeout) + + got = await loop.run_in_executor(None, _wait) + if not got: + # Timeout — loop around to re-check subscriber state. continue - _last_data = data + ev.clear() + + data, seq = analyzer.get_frequency_data_versioned() + if data is None or seq == last_seq: + continue + last_seq = seq # Pre-serialize once for all subscribers (avoids per-client JSON encoding) text = json.dumps({"type": "audio_data", "data": data}, separators=(',', ':')) @@ -198,13 +220,11 @@ class ConnectionManager: for ws in failed: await self.disconnect(ws) - await asyncio.sleep(interval) - except asyncio.CancelledError: break except Exception as e: logger.error("Error in audio broadcast: %s", e) - await asyncio.sleep(interval) + await asyncio.sleep(idle_interval) def status_changed( self, old: dict[str, Any] | None, new: dict[str, Any] diff --git a/media_server/static/css/styles.css b/media_server/static/css/styles.css index ccf91aa..254331d 100644 --- a/media_server/static/css/styles.css +++ b/media_server/static/css/styles.css @@ -4599,23 +4599,31 @@ body.visualizer-active .vinyl-stage .spectrogram-canvas { metadata lives in the masthead beside the stage. ════════════════════════════════════════════════════════════════ */ -/* Glow: soft ambient halo behind the sleeve */ +/* Glow: soft ambient halo behind the sleeve. + Performance trick: render the image at 25% × 25% of the stage and + stretch it via transform: scale(4). Filter runs on the smaller + element (16× less area), and scale just upsamples the already-blurred + result. Visually identical to a blur(34px) over the full-size image + but ~10-16× cheaper on track-switch repaints. */ .vinyl-stage > #album-art-glow { position: absolute; - inset: 0; - width: 100%; - height: 100%; + top: 50%; + left: 50%; + width: 25%; + height: 25%; border-radius: 0; object-fit: cover; - filter: blur(34px) saturate(1.6); + filter: blur(9px) saturate(1.6); opacity: 0.45; z-index: 0; pointer-events: none; - transform: scale(1.05); + transform: translate(-50%, -50%) scale(4.2); + transform-origin: center; + will-change: transform, opacity; } :root[data-theme="light"] .vinyl-stage > #album-art-glow { opacity: 0.26; - filter: blur(40px) saturate(1.8); + filter: blur(10px) saturate(1.8); } /* Honour reduced-motion: kill breathing pulse */ @@ -4683,7 +4691,8 @@ body.visualizer-active .vinyl-stage .spectrogram-canvas { margin: 0; background: transparent; filter: contrast(0.96) saturate(0.92); - transition: filter 0.6s ease; + /* No transition on filter: the values are static and a 600ms ease + on a track-switch swap would force a long compositor pass. */ } /* Crossfade on artwork swap. Class toggled by player.js right before @@ -6576,6 +6585,7 @@ footer .separator { color: var(--ink-ghost); margin: 0 8px; } } @media (max-width: 980px) { .now-playing { grid-template-columns: 1fr; gap: 40px; } + .now-playing .track-masthead { padding-right: 0; } } /* Vinyl/disc/tonearm rules live in the SLEEVE FRAME section under @@ -6598,6 +6608,7 @@ footer .separator { color: var(--ink-ghost); margin: 0 8px; } flex-direction: column; justify-content: center; padding-top: 0; + padding-right: clamp(12px, 1.5vw, 24px); gap: 0; } @@ -6702,7 +6713,9 @@ footer .separator { color: var(--ink-ghost); margin: 0 8px; } font-size: 9px; letter-spacing: 0.22em; text-transform: uppercase; - color: var(--ink-faint); + /* Match the .kicker (NOW PLAYING) color so the metadata grid reads + as part of the same typographic system. */ + color: var(--copper); margin-bottom: 8px; } .now-playing .meta-cell .value { @@ -6753,21 +6766,24 @@ footer .separator { color: var(--ink-ghost); margin: 0 8px; } opacity: 0.92; transform-origin: bottom; border-radius: 99px 99px 0 0; - height: var(--bar-h, 40%); + /* Bars are full-height boxes; the visible height is driven by + transform: scaleY. Transforms are GPU-composited, so per-frame + updates skip layout/paint entirely. */ + height: 100%; + transform: scaleY(var(--bar-h-scale, 0.4)); animation: sr-snap-bar 1.1s ease-in-out infinite; animation-delay: var(--bar-delay, 0s); animation-play-state: paused; - transition: height 60ms linear; - will-change: height; + transition: transform 50ms linear; + will-change: transform; } :root[data-playstate="playing"] .now-playing .spectrum span { animation-play-state: running; } -/* When real audio data is driving heights, freeze the CSS animation - so JS-set heights aren't overridden by the keyframe. */ +/* When real audio data is driving the bars, freeze the synthetic CSS + animation so JS-set transforms aren't overridden by the keyframe. */ body.audio-spectrum-live .now-playing .spectrum span { animation: none !important; - transition: height 50ms linear; } @keyframes sr-snap-bar { 0%, 100% { transform: scaleY(0.4); } @@ -6949,7 +6965,7 @@ body.audio-spectrum-live .now-playing .spectrum span { .now-playing .vu-volume #volume-slider { -webkit-appearance: none; appearance: none; - width: 80px; + width: 64px; height: 2px; background: var(--rule-strong); border-radius: 0; @@ -6985,7 +7001,7 @@ body.audio-spectrum-live .now-playing .spectrum span { .now-playing .vu-meter { position: relative; - width: 140px; + width: 120px; height: 60px; background: linear-gradient(180deg, #1a1610 0%, #0e0c08 100%); border: 1px solid var(--rule-strong); @@ -7044,6 +7060,28 @@ body.audio-spectrum-live .now-playing .spectrum span { font-weight: 400; } +/* Light theme — paper-faced VU meter (vintage cream gauge instead of black) */ +:root[data-theme="light"] .now-playing .vu-meter { + background: linear-gradient(180deg, #FAF6EE 0%, #E8E0CE 100%); + border-color: var(--rule-strong); + box-shadow: + inset 0 1px 2px rgba(26, 23, 21, 0.08), + inset 0 0 24px rgba(31, 78, 61, 0.05); +} +:root[data-theme="light"] .now-playing .vu-meter::before { + background: repeating-conic-gradient(from 195deg at 50% 100%, + transparent 0deg 4deg, + rgba(26, 23, 21, 0.18) 4deg 5deg, + transparent 5deg 9deg); +} +:root[data-theme="light"] .now-playing .vu-meter::after { + color: var(--ink-mute); +} +:root[data-theme="light"] .now-playing .vu-needle { + background: linear-gradient(to top, var(--copper) 0%, var(--copper-lo) 70%, var(--ink) 100%); + box-shadow: 0 0 6px rgba(31, 78, 61, 0.25); +} + /* Mobile VU cluster: stack below controls */ @media (max-width: 720px) { .now-playing .controls { flex-wrap: wrap; } @@ -8771,18 +8809,27 @@ body.is-fullscreen-player .fs-bloom { to { opacity: 0.22; transform: scale(1); } } +/* Performance trick (matches the vinyl-stage glow): render the bloom + image at 20% of the viewport and stretch it via scale(~6). Blur runs + over a 25× smaller area, so a track-switch repaint of the bloom + collapses from O(viewport-pixels × 110² ) to O(viewport/25 × 18²). */ body.is-fullscreen-player .fs-bloom #fs-bloom-art { - width: 100%; - height: 100%; + position: absolute; + top: 50%; + left: 50%; + width: 20%; + height: 20%; object-fit: cover; - filter: blur(110px) saturate(1.6); - transform: scale(1.18); + filter: blur(18px) saturate(1.6); + transform: translate(-50%, -50%) scale(5.9); + transform-origin: center; animation: fs-bloom-drift 28s ease-in-out infinite alternate; + will-change: transform; } @keyframes fs-bloom-drift { - from { transform: scale(1.18) translate3d(-1.5%, -1%, 0); } - to { transform: scale(1.22) translate3d(2%, 1.5%, 0); } + from { transform: translate(-50%, -50%) scale(5.9) translate3d(-1.5%, -1%, 0); } + to { transform: translate(-50%, -50%) scale(6.1) translate3d(2%, 1.5%, 0); } } /* Subtle paper-grain veil over the bloom — keeps it from looking flat. */ diff --git a/media_server/static/js/app.js b/media_server/static/js/app.js index 1d87d2e..beafb85 100644 --- a/media_server/static/js/app.js +++ b/media_server/static/js/app.js @@ -180,8 +180,10 @@ window.addEventListener('DOMContentLoaded', async () => { const frag = document.createDocumentFragment(); for (let i = 0; i < SPECTRUM_BARS; i++) { const s = document.createElement('span'); - // Pseudo-random heights for the synthetic CSS animation phase - s.style.setProperty('--bar-h', (25 + Math.abs(Math.sin(i * 0.7)) * 70).toFixed(0) + '%'); + // Pseudo-random initial scaleY for the synthetic CSS-only + // animation (used while no real audio is flowing). + const scale = (0.25 + Math.abs(Math.sin(i * 0.7)) * 0.70).toFixed(2); + s.style.setProperty('--bar-h-scale', scale); s.style.setProperty('--bar-delay', (-Math.random() * 1.1).toFixed(2) + 's'); frag.appendChild(s); } diff --git a/media_server/static/js/background.js b/media_server/static/js/background.js index ca7c6e6..415f276 100644 --- a/media_server/static/js/background.js +++ b/media_server/static/js/background.js @@ -236,27 +236,54 @@ export function updateBackgroundColors() { // ---- Render loop ---- +// Cached step into the bins array; recomputed only when bins.length +// changes (which happens at most once after the first audio frame +// arrives or when num_bins is reconfigured). +let bgBinsLength = -1; +let bgBinsStep = 1; +// Last applied resolution — drawing with stale viewport is harmless, +// but we still need to refresh the uniform after the resize listener +// has updated the canvas. +let bgLastResW = -1; +let bgLastResH = -1; + function renderBackgroundFrame() { bgAnimFrame = requestAnimationFrame(renderBackgroundFrame); const gl = bgGL; if (!gl || !bgUniforms) return; - resizeBackgroundCanvas(); - gl.viewport(0, 0, bgCanvas.width, bgCanvas.height); + // Resize listener already keeps canvas dimensions in sync — only + // touch the viewport when the canvas actually changed size, so the + // per-frame path doesn't read window.innerWidth (a layout-flushing + // property). + if (bgCanvas.width !== bgLastResW || bgCanvas.height !== bgLastResH) { + bgLastResW = bgCanvas.width; + bgLastResH = bgCanvas.height; + gl.viewport(0, 0, bgLastResW, bgLastResH); + gl.uniform2f(bgUniforms.resolution, bgLastResW, bgLastResH); + } const time = performance.now() / 1000 - bgStartTime; - // Smooth audio data from the imported frequencyData (shared with visualizer) + // Smooth audio data from the imported frequencyData (shared with visualizer). + // Backend may send float bins (legacy) or int×1000 (new); .scale tells us which. if (frequencyData && frequencyData.frequencies) { const bins = frequencyData.frequencies; - const step = Math.max(1, Math.floor(bins.length / BG_BAND_COUNT)); + const scale = frequencyData.scale && frequencyData.scale > 0 + ? 1.0 / frequencyData.scale : 1.0; + if (bins.length !== bgBinsLength) { + bgBinsLength = bins.length; + bgBinsStep = Math.max(1, Math.floor(bgBinsLength / BG_BAND_COUNT)); + } + const step = bgBinsStep; for (let i = 0; i < BG_BAND_COUNT; i++) { - const idx = Math.min(i * step, bins.length - 1); - const target = bins[idx] || 0; + let idx = i * step; + if (idx >= bgBinsLength) idx = bgBinsLength - 1; + const target = (bins[idx] || 0) * scale; bgSmoothedBands[i] += (target - bgSmoothedBands[i]) * (1 - BG_SMOOTHING); } - const targetBass = frequencyData.bass || 0; + const targetBass = (frequencyData.bass || 0) * scale; bgSmoothedBass += (targetBass - bgSmoothedBass) * (1 - BG_SMOOTHING); } else { // Gentle decay when no audio @@ -267,7 +294,6 @@ function renderBackgroundFrame() { } // Set uniforms (locations cached at init, colors cached on change) - gl.uniform2f(bgUniforms.resolution, bgCanvas.width, bgCanvas.height); gl.uniform1f(bgUniforms.time, time); gl.uniform1f(bgUniforms.bass, bgSmoothedBass); gl.uniform1fv(bgUniforms.bands, bgSmoothedBands); diff --git a/media_server/static/js/core.js b/media_server/static/js/core.js index dabc61a..6b7959c 100644 --- a/media_server/static/js/core.js +++ b/media_server/static/js/core.js @@ -140,7 +140,10 @@ export function cacheDom() { // Timing constants export const VOLUME_THROTTLE_MS = 16; -export const POSITION_INTERPOLATION_MS = 100; +// 250ms is plenty for sub-second progress; the inline updateProgress +// also short-circuits when the rounded second hasn't moved, so there's +// no visible difference for the user. +export const POSITION_INTERPOLATION_MS = 250; export const SEARCH_DEBOUNCE_MS = 200; export const TOAST_DURATION_MS = 3000; export const WS_BACKOFF_BASE_MS = 3000; diff --git a/media_server/static/js/player.js b/media_server/static/js/player.js index b451057..2aac0ee 100644 --- a/media_server/static/js/player.js +++ b/media_server/static/js/player.js @@ -165,6 +165,9 @@ export function applyAccentColor(color, hover) { const dot = document.getElementById('accentDot'); if (dot) dot.style.background = color; updateBackgroundColors(); + // Refresh the cached accent in the visualizer so the gradient + // rebuilds on its next frame instead of querying CSS every frame. + refreshVisualizerAccent(); } export function renderAccentSwatches() { @@ -215,12 +218,49 @@ export function setVisualizerEnabled(value) { visualizerEnabled = !!value; localStorage.setItem('visualizerEnabled', visualizerEnabled); } +let visualizerCanvas = null; // Cached canvas DOM ref let visualizerCtx = null; +let visualizerGradient = null; // Pre-built gradient (rebuilt on accent change / resize) let visualizerAnimFrame = null; -export let frequencyData = null; -export function setFrequencyData(value) { frequencyData = value; } +export let frequencyData = null; // Latest payload from backend (int-scaled or float-scaled) +let frequencyDataVersion = 0; // Bumped on every setFrequencyData +let lastRenderedVersion = -1; // Last version rendered in renderVisualizerFrame +let frequenciesScale = 1.0; // Backend scale factor (1000 → ints, 1 → floats) +export function setFrequencyData(value) { + frequencyData = value; + frequencyDataVersion++; + // Backend may send integer-quantized bins (scale=1000) or legacy floats (no scale). + if (value && typeof value.scale === 'number' && value.scale > 0) { + frequenciesScale = 1.0 / value.scale; + } else { + frequenciesScale = 1.0; + } +} let smoothedFrequencies = null; const VISUALIZER_SMOOTHING = 0.15; +// Cached accent — refreshed by applyAccentColor() rather than on every frame. +let cachedAccentHex = '#1db954'; +let cachedAccentRGB = '29,185,84'; +function parseAccentHex(hex) { + const h = (hex || '').trim().replace('#', ''); + if (h.length < 6) return null; + const r = parseInt(h.slice(0, 2), 16); + const g = parseInt(h.slice(2, 4), 16); + const b = parseInt(h.slice(4, 6), 16); + if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return null; + return `${r},${g},${b}`; +} +export function refreshVisualizerAccent() { + const accentHex = getComputedStyle(document.documentElement) + .getPropertyValue('--accent').trim(); + if (accentHex) { + cachedAccentHex = accentHex; + const rgb = parseAccentHex(accentHex); + if (rgb) cachedAccentRGB = rgb; + } + // Force gradient rebuild on next frame. + visualizerGradient = null; +} export async function checkVisualizerAvailability() { try { @@ -274,15 +314,28 @@ export function applyVisualizerMode() { } function initVisualizerCanvas() { - const canvas = document.getElementById('spectrogram-canvas'); - if (!canvas) return; - visualizerCtx = canvas.getContext('2d'); - canvas.width = 300; - canvas.height = 64; + visualizerCanvas = document.getElementById('spectrogram-canvas'); + if (!visualizerCanvas) return; + visualizerCtx = visualizerCanvas.getContext('2d'); + visualizerCanvas.width = 300; + visualizerCanvas.height = 64; + visualizerGradient = null; // Force rebuild + refreshVisualizerAccent(); +} + +function buildVisualizerGradient() { + if (!visualizerCtx || !visualizerCanvas) return null; + const h = visualizerCanvas.height; + const grad = visualizerCtx.createLinearGradient(0, 0, 0, h); + grad.addColorStop(0, `rgba(${cachedAccentRGB},1)`); + grad.addColorStop(1, `rgba(${cachedAccentRGB},0.19)`); + return grad; } function startVisualizerRender() { if (visualizerAnimFrame) return; + // Cache editorial spectrum bar refs once per start. + cacheEditorialSpectrumBars(); renderVisualizerFrame(); } @@ -291,62 +344,70 @@ export function stopVisualizerRender() { cancelAnimationFrame(visualizerAnimFrame); visualizerAnimFrame = null; } - const canvas = document.getElementById('spectrogram-canvas'); - if (visualizerCtx && canvas) { - visualizerCtx.clearRect(0, 0, canvas.width, canvas.height); + if (visualizerCtx && visualizerCanvas) { + visualizerCtx.clearRect(0, 0, visualizerCanvas.width, visualizerCanvas.height); } frequencyData = null; + frequencyDataVersion++; // Force next render to redraw cleared state + lastRenderedVersion = -1; smoothedFrequencies = null; document.body.classList.remove('audio-spectrum-live'); - // Reset spectrum bar heights so the synthetic CSS animation takes back over - document.querySelectorAll('.now-playing .spectrum > span').forEach(s => { - s.style.height = ''; - }); + // Reset spectrum bar transforms so the synthetic CSS animation takes back over. + if (editorialSpectrumBars) { + for (let i = 0; i < editorialSpectrumBars.length; i++) { + editorialSpectrumBars[i].style.transform = ''; + } + } + // Drop cached bars so next start re-queries. + editorialSpectrumBars = null; + editorialSpectrumLastScale = null; } function renderVisualizerFrame() { visualizerAnimFrame = requestAnimationFrame(renderVisualizerFrame); - const canvas = document.getElementById('spectrogram-canvas'); - if (!frequencyData || !visualizerCtx || !canvas) return; + // VU needle + position progress always tick — they read live state + // not bound to spectrum payloads. Keeping them in this single rAF + // is cheaper than running a second rAF loop just for the needle. + tickVuNeedle(); + + if (!frequencyData || !visualizerCtx || !visualizerCanvas) return; + + // FPS gate: backend pushes ~visualizer_fps Hz; the monitor refreshes + // at 60-144 Hz. Re-rendering an unchanged frame is wasted work, so + // bail when no new payload has arrived since the last draw. + if (frequencyDataVersion === lastRenderedVersion) return; + lastRenderedVersion = frequencyDataVersion; const bins = frequencyData.frequencies; const numBins = bins.length; - const w = canvas.width; - const h = canvas.height; + const w = visualizerCanvas.width; + const h = visualizerCanvas.height; const gap = 2; const barWidth = (w / numBins) - gap; - const accent = getComputedStyle(document.documentElement) - .getPropertyValue('--accent').trim(); + const scale = frequenciesScale; if (!smoothedFrequencies || smoothedFrequencies.length !== numBins) { - smoothedFrequencies = new Array(numBins).fill(0); + smoothedFrequencies = new Float32Array(numBins); } for (let i = 0; i < numBins; i++) { + const v = bins[i] * scale; smoothedFrequencies[i] = smoothedFrequencies[i] * VISUALIZER_SMOOTHING - + bins[i] * (1 - VISUALIZER_SMOOTHING); + + v * (1 - VISUALIZER_SMOOTHING); } - visualizerCtx.clearRect(0, 0, w, h); + if (!visualizerGradient) visualizerGradient = buildVisualizerGradient(); + visualizerCtx.clearRect(0, 0, w, h); + visualizerCtx.fillStyle = visualizerGradient; + visualizerCtx.beginPath(); for (let i = 0; i < numBins; i++) { const barHeight = Math.max(1, smoothedFrequencies[i] * h); const x = i * (barWidth + gap) + gap / 2; const y = h - barHeight; - - const grad = visualizerCtx.createLinearGradient(x, y, x, h); - grad.addColorStop(0, accent); - grad.addColorStop(1, accent + '30'); - - visualizerCtx.fillStyle = grad; - visualizerCtx.beginPath(); visualizerCtx.roundRect(x, y, barWidth, barHeight, 1.5); - visualizerCtx.fill(); } - - // Bass-driven album-art scale + glow pulse removed — the - // "burst" looked unnatural on the sleeve. Spectrum bars + - // VU needle remain the audio-reactive elements. + visualizerCtx.fill(); // Drive the editorial .spectrum bars from the same frequency data. updateEditorialSpectrum(smoothedFrequencies, numBins); @@ -357,36 +418,79 @@ function renderVisualizerFrame() { // dominate); a linear mapping leaves the right half of the spectrum // looking dead. Use a logarithmic frequency-to-bar mapping plus a // per-bar high-end gain so all bars carry visible motion. -function updateEditorialSpectrum(bins, numBins) { - const root = document.querySelector('.now-playing .spectrum'); - if (!root) return; - const bars = root.children; - const barCount = bars.length; - if (!barCount) return; - document.body.classList.add('audio-spectrum-live'); +let editorialSpectrumBars = null; // Live HTMLCollection cached at start +let editorialSpectrumBarCount = 0; +let editorialSpectrumLastScale = null; // Float32Array of last applied scaleY × 1000 (int rounded) +let editorialBarRanges = null; // Pre-computed [startIdx,endIdx] pairs per bar +let editorialBarGains = null; // Pre-computed per-bar gain +let editorialBarRangesForBins = -1; // numBins last used to compute ranges - // Skip the very lowest bin (DC + sub-rumble) which often dominates. +function cacheEditorialSpectrumBars() { + const root = document.querySelector('.now-playing .spectrum'); + if (!root) { + editorialSpectrumBars = null; + editorialSpectrumBarCount = 0; + return; + } + editorialSpectrumBars = root.children; + editorialSpectrumBarCount = editorialSpectrumBars.length; + editorialSpectrumLastScale = new Int16Array(editorialSpectrumBarCount); + editorialSpectrumLastScale.fill(-1); + // Pre-compute per-bar gain (constant for the lifetime of the bar list). + editorialBarGains = new Float32Array(editorialSpectrumBarCount); + for (let i = 0; i < editorialSpectrumBarCount; i++) { + editorialBarGains[i] = 1 + (i / editorialSpectrumBarCount) * 0.8; + } + editorialBarRangesForBins = -1; // Force range recompute on next call +} + +function recomputeEditorialBarRanges(numBins) { + const barCount = editorialSpectrumBarCount; + editorialBarRanges = new Int16Array(barCount * 2); const lowBin = 1; const highBin = numBins - 1; + const span = highBin - lowBin; for (let i = 0; i < barCount; i++) { - // Logarithmic mapping: equal-area slices of the audible spectrum - // map to equal numbers of bars. Each bar covers a wider bin range - // toward the highs so they get amplified naturally. const t0 = i / barCount; const t1 = (i + 1) / barCount; - const startIdx = Math.max(lowBin, Math.floor(lowBin + Math.pow(t0, 2.0) * (highBin - lowBin))); - const endIdx = Math.max(startIdx + 1, Math.floor(lowBin + Math.pow(t1, 2.0) * (highBin - lowBin))); + const startIdx = Math.max(lowBin, Math.floor(lowBin + t0 * t0 * span)); + const endIdx = Math.max(startIdx + 1, Math.floor(lowBin + t1 * t1 * span)); + editorialBarRanges[i * 2] = startIdx; + editorialBarRanges[i * 2 + 1] = Math.min(endIdx, numBins); + } + editorialBarRangesForBins = numBins; +} + +function updateEditorialSpectrum(bins, numBins) { + if (!editorialSpectrumBars) cacheEditorialSpectrumBars(); + const barCount = editorialSpectrumBarCount; + if (!barCount) return; + if (editorialBarRangesForBins !== numBins) recomputeEditorialBarRanges(numBins); + document.body.classList.add('audio-spectrum-live'); + + const ranges = editorialBarRanges; + const gains = editorialBarGains; + const lastScale = editorialSpectrumLastScale; + const bars = editorialSpectrumBars; + for (let i = 0; i < barCount; i++) { + const startIdx = ranges[i * 2]; + const endIdx = ranges[i * 2 + 1]; let peak = 0; - for (let j = startIdx; j < endIdx && j < numBins; j++) { - if (bins[j] > peak) peak = bins[j]; + for (let j = startIdx; j < endIdx; j++) { + const v = bins[j]; + if (v > peak) peak = v; } - // Per-bar high-end gain: 1.0 at the lowest bar, ~1.8 at the highest. - // Backend now ships AGC-normalized bins (peak ~1, transients up to 1.5) - // so the master multiplier stays modest to avoid perma-clipping. - const gain = 1 + (i / barCount) * 0.8; - // Floor at 12% so silent bars are still visually present. - const pct = Math.max(12, Math.min(100, peak * 65 * gain)); - bars[i].style.height = pct + '%'; + // Backend ships AGC-normalized bins (peak ~1, transients up to ~1.5). + // Map to a 0.12..1.0 scaleY, with 0.12 floor so silent bars stay visible. + const raw = peak * 0.65 * gains[i]; + const scaleY = raw < 0.12 ? 0.12 : (raw > 1 ? 1 : raw); + // Quantize to 1/1000 — anything finer is invisible. Skip the DOM + // write when the bar hasn't moved. + const q = (scaleY * 1000) | 0; + if (q === lastScale[i]) continue; + lastScale[i] = q; + // transform: scaleY runs on the compositor — no layout/paint. + bars[i].style.transform = `scaleY(${scaleY.toFixed(3)})`; } } @@ -616,18 +720,46 @@ export function setupProgressDrag(bar, fill) { // Replace the album-art src and replay the .is-swapping CSS animation // so the new artwork crossfades in instead of popping. Re-toggling the // class across rAF restarts the keyframes even if it was already on. +// +// `forceAnim=false` skips the keyframe-restart reflow when the element +// has never run the swap animation before — saves a synchronous layout +// flush on first paint. The reflow IS still required when the class +// is currently applied; otherwise the browser coalesces add+remove and +// the keyframes don't replay. function swapArtworkSrc(imgEl, newSrc) { if (!imgEl) return; if (imgEl.src === newSrc) return; - imgEl.classList.remove('is-swapping'); - void imgEl.offsetWidth; + const wasSwapping = imgEl.classList.contains('is-swapping'); + if (wasSwapping) { + imgEl.classList.remove('is-swapping'); + // Forced reflow restarts the keyframes — only needed when we have + // to interrupt an in-flight animation. + void imgEl.offsetWidth; + } imgEl.src = newSrc; imgEl.classList.add('is-swapping'); } +// Hash of the last fully-rendered status payload — lets us skip +// updateUI altogether when the backend re-broadcasts the same state. +let lastStatusFingerprint = null; +function statusFingerprint(s) { + return [ + s.state, s.title, s.artist, s.album, s.volume, s.muted, + s.duration, s.source, s.album_art_url, s.position + ].join('|'); +} + export function updateUI(status) { setLastStatus(status); + // Idempotence: if nothing meaningful changed, skip the entire DOM + // pass. Track switches arrive as 1-3 status_update broadcasts in + // quick succession; this gates the redundant ones. + const fingerprint = statusFingerprint(status); + if (fingerprint === lastStatusFingerprint) return; + lastStatusFingerprint = fingerprint; + const fallbackTitle = status.state === 'idle' ? t('player.no_media') : t('player.title_unavailable'); dom.trackTitle.textContent = status.title || fallbackTitle; dom.artist.textContent = status.artist || ''; @@ -654,7 +786,10 @@ export function updateUI(status) { const placeholderArt = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300'%3E%3Cpath fill='%236a6a6a' opacity='0.35' d='M150 80c-38.66 0-70 31.34-70 70s31.34 70 70 70 70-31.34 70-70-31.34-70-70-70zm0 20c27.614 0 50 22.386 50 50s-22.386 50-50 50-50-22.386-50-50 22.386-50 50-50zm0 30a20 20 0 100 40 20 20 0 000-40z'/%3E%3C/svg%3E"; const placeholderGlow = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300'%3E%3Crect fill='%23282828' width='300' height='300'/%3E%3C/svg%3E"; if (artworkSource) { - fetch(`/api/media/artwork?_=${Date.now()}`, { + // No cache-buster: when album_art_url is unchanged the + // browser can reuse the decoded bitmap. The artworkKey gate + // already skips fetches when the user hasn't switched tracks. + fetch('/api/media/artwork', { headers: getAuthHeaders() }) .then(r => r.ok ? r.blob() : null) @@ -664,8 +799,11 @@ export function updateUI(status) { const url = URL.createObjectURL(blob); currentArtworkBlobUrl = url; swapArtworkSrc(dom.albumArt, url); - dom.miniAlbumArt.src = url; - if (dom.albumArtGlow) dom.albumArtGlow.src = url; + if (dom.miniAlbumArt.src !== url) dom.miniAlbumArt.src = url; + if (dom.albumArtGlow && dom.albumArtGlow.src !== url) dom.albumArtGlow.src = url; + // Mirror to fullscreen bloom directly — drops the + // MutationObserver fan-out path. + syncFullscreenBloomArt(url); if (oldBlobUrl) setTimeout(() => URL.revokeObjectURL(oldBlobUrl), 1000); }) .catch(err => console.error('Artwork fetch failed:', err)); @@ -675,17 +813,22 @@ export function updateUI(status) { currentArtworkBlobUrl = null; } swapArtworkSrc(dom.albumArt, placeholderArt); - dom.miniAlbumArt.src = placeholderArt; - if (dom.albumArtGlow) dom.albumArtGlow.src = placeholderGlow; + if (dom.miniAlbumArt.src !== placeholderArt) dom.miniAlbumArt.src = placeholderArt; + if (dom.albumArtGlow && dom.albumArtGlow.src !== placeholderGlow) dom.albumArtGlow.src = placeholderGlow; + syncFullscreenBloomArt(placeholderGlow); } } if (status.duration && status.position !== null) { + // Only redo the progress DOM when position actually changed. + const positionChanged = + status.duration !== currentDuration || + Math.abs((status.position || 0) - (lastPositionValue || 0)) > 0.05; setCurrentDuration(status.duration); setCurrentPosition(status.position); lastPositionUpdate = Date.now(); lastPositionValue = status.position; - updateProgress(status.position, status.duration); + if (positionChanged) updateProgress(status.position, status.duration); } if (!isUserAdjustingVolume) { @@ -730,17 +873,24 @@ export function updateUI(status) { // FFT data the visualizer feeds in). When audio capture isn't // running, fall back to a synthetic wobble bounded by the volume // slider position so the needle still looks alive. -let vuWobbleHandle = null; +// +// One unified rAF drives both the spectrum and the VU needle (see +// renderVisualizerFrame → tickVuNeedle). If the visualizer isn't +// rendering, a separate rAF takes over solely for the needle. +let vuStandaloneHandle = null; let vuWobbleStart = 0; let vuLevelSmoothed = 0; +let vuNeedleEl = null; // Cached needle element +let vuVolumeSliderEl = null; // Cached slider element +let vuLastAppliedDeg = -999; // Skip DOM writes when angle unchanged const VU_LEVEL_ATTACK = 0.7; // Fast climb so the needle catches musical hits const VU_LEVEL_RELEASE = 0.25; // Faster fall so it swings between hits, not pins function readAudioLevel() { if (!frequencyData) return null; - // Backend sends a true loudness signal (RMS-derived dB, 0..1). - // The bins are renormalized per frame so peak-of-bins is useless for level. - if (typeof frequencyData.level === 'number') return frequencyData.level; + // Backend sends a true loudness signal (RMS-derived dB, 0..1) — + // either as float (legacy) or scaled int (new format). + if (typeof frequencyData.level === 'number') return frequencyData.level * frequenciesScale; if (!frequencyData.frequencies) return null; const bins = frequencyData.frequencies; if (!bins.length) return null; @@ -748,52 +898,62 @@ function readAudioLevel() { for (let i = 1; i < bins.length; i++) { if (bins[i] > peak) peak = bins[i]; } - return Math.min(1, peak * 1.4); + return Math.min(1, peak * frequenciesScale * 1.4); +} + +function tickVuNeedle() { + if (!vuNeedleEl) vuNeedleEl = document.getElementById('vuNeedle'); + if (!vuNeedleEl) return; + const audioLevel = readAudioLevel(); + let target; + if (audioLevel != null) { + const k = audioLevel > vuLevelSmoothed ? VU_LEVEL_ATTACK : VU_LEVEL_RELEASE; + vuLevelSmoothed = vuLevelSmoothed * (1 - k) + audioLevel * k; + target = -22 + vuLevelSmoothed * 44; + } else { + if (!vuVolumeSliderEl) vuVolumeSliderEl = document.getElementById('volume-slider'); + const vol = vuVolumeSliderEl ? Number(vuVolumeSliderEl.value) || 0 : 0; + const base = -22 + (vol / 100) * 44; + const mag = Math.max(2, Math.min(14, vol * 0.16)); + const t = (performance.now() - vuWobbleStart) / 1000; + target = base + + Math.sin(t * 6.3) * mag * 0.55 + + Math.sin(t * 11.7 + 1.3) * mag * 0.30 + + (Math.random() - 0.5) * mag * 0.30; + } + // Quantize to 0.1° — finer is invisible. Skip when unchanged. + const q = Math.round(target * 10) / 10; + if (q === vuLastAppliedDeg) return; + vuLastAppliedDeg = q; + vuNeedleEl.style.transform = `rotate(${q}deg)`; } function startVuWobble() { - if (vuWobbleHandle) return; vuWobbleStart = performance.now(); - const tick = () => { - const needle = document.getElementById('vuNeedle'); - if (needle) { - // Loopback capture is post-volume on Windows/macOS, so the - // measured level already reflects the output knob — no extra - // (vol/100) attenuation needed. - const audioLevel = readAudioLevel(); - let target; - if (audioLevel != null) { - // Real audio: apply attack/release smoothing for - // analog-feeling ballistics. - const k = audioLevel > vuLevelSmoothed ? VU_LEVEL_ATTACK : VU_LEVEL_RELEASE; - vuLevelSmoothed = vuLevelSmoothed * (1 - k) + audioLevel * k; - target = -22 + vuLevelSmoothed * 44; - } else { - const slider = document.getElementById('volume-slider'); - const vol = slider ? Number(slider.value) || 0 : 0; - const base = -22 + (vol / 100) * 44; - const mag = Math.max(2, Math.min(14, vol * 0.16)); - const t = (performance.now() - vuWobbleStart) / 1000; - target = base - + Math.sin(t * 6.3) * mag * 0.55 - + Math.sin(t * 11.7 + 1.3) * mag * 0.30 - + (Math.random() - 0.5) * mag * 0.30; - } - needle.style.transform = `rotate(${target}deg)`; + // If the visualizer rAF is already running, it ticks the needle for us. + if (visualizerAnimFrame) return; + if (vuStandaloneHandle) return; + const standalone = () => { + tickVuNeedle(); + // Stop ourselves once the unified visualizer loop is up. + if (visualizerAnimFrame) { + vuStandaloneHandle = null; + return; } - vuWobbleHandle = requestAnimationFrame(tick); + vuStandaloneHandle = requestAnimationFrame(standalone); }; - vuWobbleHandle = requestAnimationFrame(tick); + vuStandaloneHandle = requestAnimationFrame(standalone); } function stopVuWobble() { - if (vuWobbleHandle) { - cancelAnimationFrame(vuWobbleHandle); - vuWobbleHandle = null; + if (vuStandaloneHandle) { + cancelAnimationFrame(vuStandaloneHandle); + vuStandaloneHandle = null; } vuLevelSmoothed = 0; - const needle = document.getElementById('vuNeedle'); - if (needle) needle.style.transform = 'rotate(-22deg)'; + vuLastAppliedDeg = -999; + if (!vuNeedleEl) vuNeedleEl = document.getElementById('vuNeedle'); + if (vuNeedleEl) vuNeedleEl.style.transform = 'rotate(-22deg)'; } export function updatePlaybackState(state) { @@ -830,30 +990,58 @@ export function updatePlaybackState(state) { } } +// Cache last applied progress values so we can skip DOM writes when the +// rounded second hasn't moved. Width is quantized to 0.1% — finer is +// invisible but would still trigger compositor work. +let lastProgressTenths = -1; // 0..1000 (0.1% increments) +let lastProgressSec = -1; +let lastDurationSec = -1; +let cachedMiniBar = null; + function updateProgress(position, duration) { const percent = (position / duration) * 100; - const widthStr = `${percent}%`; - const currentStr = formatTime(position); - const totalStr = formatTime(duration); + const tenths = Math.round(percent * 10); // 0..1000 const posRound = Math.round(position); const durRound = Math.round(duration); - dom.progressFill.style.width = widthStr; - dom.currentTime.textContent = currentStr; - dom.totalTime.textContent = totalStr; - if (dom.metaElapsed) dom.metaElapsed.textContent = currentStr; - if (dom.metaLength) dom.metaLength.textContent = totalStr; - dom.progressBar.dataset.duration = duration; - dom.progressBar.setAttribute('aria-valuenow', posRound); - dom.progressBar.setAttribute('aria-valuemax', durRound); + const widthChanged = tenths !== lastProgressTenths; + const posChanged = posRound !== lastProgressSec; + const durChanged = durRound !== lastDurationSec; - dom.miniProgressFill.style.width = widthStr; - dom.miniCurrentTime.textContent = currentStr; - dom.miniTotalTime.textContent = totalStr; - if (dom.miniPlayer) dom.miniPlayer.style.setProperty('--mini-progress', widthStr); - const miniBar = document.getElementById('mini-progress-bar'); - miniBar.setAttribute('aria-valuenow', posRound); - miniBar.setAttribute('aria-valuemax', durRound); + if (widthChanged) { + lastProgressTenths = tenths; + const widthStr = (tenths / 10) + '%'; + dom.progressFill.style.width = widthStr; + dom.miniProgressFill.style.width = widthStr; + if (dom.miniPlayer) dom.miniPlayer.style.setProperty('--mini-progress', widthStr); + } + + if (posChanged) { + lastProgressSec = posRound; + const currentStr = formatTime(position); + dom.currentTime.textContent = currentStr; + if (dom.metaElapsed) dom.metaElapsed.textContent = currentStr; + dom.miniCurrentTime.textContent = currentStr; + dom.progressBar.setAttribute('aria-valuenow', posRound); + } + + if (durChanged) { + lastDurationSec = durRound; + const totalStr = formatTime(duration); + dom.totalTime.textContent = totalStr; + if (dom.metaLength) dom.metaLength.textContent = totalStr; + dom.miniTotalTime.textContent = totalStr; + dom.progressBar.dataset.duration = duration; + dom.progressBar.setAttribute('aria-valuemax', durRound); + } + + if (posChanged || durChanged) { + if (!cachedMiniBar) cachedMiniBar = document.getElementById('mini-progress-bar'); + if (cachedMiniBar) { + if (posChanged) cachedMiniBar.setAttribute('aria-valuenow', posRound); + if (durChanged) cachedMiniBar.setAttribute('aria-valuemax', durRound); + } + } } export function startPositionInterpolation() { @@ -901,13 +1089,15 @@ function updateMuteIcon(muted) { let fsChromeIdleTimer = null; const FS_CHROME_IDLE_MS = 2500; let fsLastFocusedElement = null; -let fsBloomSyncObserver = null; -function syncFullscreenBloomArt() { - const src = document.getElementById('album-art'); +// Mirror the album-art onto #fs-bloom-art (the fullscreen ambient +// bloom). Called directly from the artwork-swap path — no +// MutationObserver, so we never repaint the 110px-radius blur twice. +function syncFullscreenBloomArt(url) { const bloom = document.getElementById('fs-bloom-art'); - if (!src || !bloom) return; - if (src.src && src.src !== bloom.src) bloom.src = src.src; + if (!bloom) return; + const target = url || (dom && dom.albumArt && dom.albumArt.src) || ''; + if (target && bloom.src !== target) bloom.src = target; } function showFsChrome() { @@ -978,16 +1168,10 @@ export function enterPlayerFullscreen() { document.body.classList.add('is-fullscreen-player'); setMiniPlayerVisible(false); updateFullscreenButtonIcons(true); + // Initial mirror — subsequent swaps are pushed by updateUI directly, + // so there is no MutationObserver in the hot path. syncFullscreenBloomArt(); - // Watch for album-art swaps so the bloom keeps up. - const src = document.getElementById('album-art'); - if (src && 'MutationObserver' in window) { - if (fsBloomSyncObserver) fsBloomSyncObserver.disconnect(); - fsBloomSyncObserver = new MutationObserver(syncFullscreenBloomArt); - fsBloomSyncObserver.observe(src, { attributes: true, attributeFilter: ['src'] }); - } - document.addEventListener('mousemove', onFsMouseMove, { passive: true }); document.addEventListener('keydown', onFsKeyDown); showFsChrome(); @@ -1017,10 +1201,6 @@ export function exitPlayerFullscreen({ skipNativeExit = false } = {}) { clearTimeout(fsChromeIdleTimer); fsChromeIdleTimer = null; } - if (fsBloomSyncObserver) { - fsBloomSyncObserver.disconnect(); - fsBloomSyncObserver = null; - } document.removeEventListener('mousemove', onFsMouseMove); document.removeEventListener('keydown', onFsKeyDown);