Files
media-player-server/media_server/static/js/background.js
T
alexei.dolgolyov 51ec1503f4
Lint & Test / test (push) Successful in 10s
perf(visualizer): cut spectrum + track-switch CPU significantly
Frontend hot path (player.js, background.js):
- visualizer rAF: drop per-frame getComputedStyle('--accent') (cached on
  applyAccentColor), build canvas LinearGradient once per accent change
  instead of 32× per frame, batch all bars into a single beginPath/fill
- FPS-gate canvas redraw via frequencyDataVersion so 60-144 Hz monitors
  stop re-rendering identical frames produced at 30 Hz on the backend
- editorial spectrum bars: replace style.height (layout) with
  transform: scaleY (compositor-only); cache bar refs, pre-compute
  per-bar gain/range, dedup writes at 1/1000 quantization
- coalesce VU needle into the visualizer rAF; cache vuNeedle ref;
  dedup angle writes at 0.1°
- updateUI: status-payload fingerprint short-circuits the redundant
  status_update broadcasts that fire during a track change
- swapArtworkSrc: only force layout reflow when keyframe is in flight;
  drop the ?_=Date.now() cache-buster so identical artwork URLs reuse
  the decoded bitmap; mini/glow imgs only re-set src when changed
- drop the fullscreen MutationObserver — fs-bloom-art is mirrored
  directly from the artwork-swap path, eliminating the second blur paint
- updateProgress: skip text writes when the rounded second hasn't moved;
  POSITION_INTERPOLATION_MS 100 → 250
- background.js: lift resizeBackgroundCanvas out of the rAF body, cache
  step, accept new int-scaled wire format

CSS:
- spectrum bars use transform: scaleY(var(--bar-h-scale)) + transition
  on transform; will-change updated to transform
- album-art-glow and fs-bloom-art switched to small-source-blur trick
  (render at 20-25% size, scale 4-6×, lower blur radius) — visually
  equivalent, ~10-25× cheaper repaint on track change
- drop unused transition: filter on .vinyl-stage #album-art

Backend (audio_analyzer.py, websocket_manager.py):
- pre-allocate windowed and cumsum buffers; replace
  np.concatenate(([0.0], np.cumsum(...))) with cumsum[0]=0 +
  np.cumsum(out=cumsum[1:]); float32 hanning window
- RMS via np.dot(mono, mono) — no astype copy, no ** temp
- int16 wire format (scale=1000) — smaller JSON, no Python float boxing
- versioned data + threading.Event so _audio_broadcast_loop is event-
  driven (ev.wait + monotonic seq dedup) instead of polling on a timer
  with the always-false `data is _last_data` identity check

ruff clean, pytest 7 passed / 3 numpy-skipped, esbuild bundle 113.6 kB.
2026-04-25 18:05:57 +03:00

343 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================
// Background: WebGL shader-based dynamic background
// ============================================================
import { frequencyData } from './player.js';
let bgCanvas = null;
let bgGL = null;
let bgProgram = null;
let bgUniforms = null; // Cached uniform locations
let bgAnimFrame = null;
let bgEnabled = localStorage.getItem('dynamicBackground') === 'true';
let bgStartTime = 0;
let bgSmoothedBands = new Float32Array(16);
let bgSmoothedBass = 0;
let bgAccentRGB = [0.114, 0.725, 0.329]; // Cached accent color (default green)
let bgBgColorRGB = [0.071, 0.071, 0.071]; // Cached page background (#121212)
const BG_BAND_COUNT = 16;
const BG_SMOOTHING = 0.12;
// ---- Shaders ----
const BG_VERT_SRC = `
attribute vec2 a_position;
void main() {
gl_Position = vec4(a_position, 0.0, 1.0);
}
`;
const BG_FRAG_SRC = `
precision mediump float;
uniform vec2 u_resolution;
uniform float u_time;
uniform float u_bass;
uniform float u_bands[16];
uniform vec3 u_accent;
uniform vec3 u_bgColor;
// Smooth noise
float hash(vec2 p) {
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
}
float noise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
f = f * f * (3.0 - 2.0 * f);
float a = hash(i);
float b = hash(i + vec2(1.0, 0.0));
float c = hash(i + vec2(0.0, 1.0));
float d = hash(i + vec2(1.0, 1.0));
return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
}
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution;
float aspect = u_resolution.x / u_resolution.y;
// Center coordinates for radial effects
vec2 center = (uv - 0.5) * vec2(aspect, 1.0);
float dist = length(center);
float angle = atan(center.y, center.x);
// Slow base animation
float t = u_time * 0.15;
// === Layer 1: Flowing wave field ===
float waves = 0.0;
for (int i = 0; i < 5; i++) {
float fi = float(i);
float freq = 1.5 + fi * 0.8;
float speed = t * (0.6 + fi * 0.15);
// Sample a band for this wave layer
int bandIdx = i * 3;
float bandVal = 0.0;
// Manual indexing (GLSL ES doesn't allow variable array index in some drivers)
for (int j = 0; j < 16; j++) {
if (j == bandIdx) bandVal = u_bands[j];
}
float amp = 0.015 + bandVal * 0.06;
waves += amp * sin(uv.x * freq * 6.2832 + speed + sin(uv.y * 3.0 + t) * 2.0);
waves += amp * 0.5 * sin(uv.y * freq * 4.0 - speed * 0.7 + cos(uv.x * 2.5 + t) * 1.5);
}
// === Layer 2: Radial pulse (bass-driven) ===
float pulse = smoothstep(0.6 + u_bass * 0.3, 0.0, dist) * (0.08 + u_bass * 0.15);
// === Layer 3: Frequency ring arcs ===
float rings = 0.0;
for (int i = 0; i < 8; i++) {
float fi = float(i);
float bandVal = 0.0;
for (int j = 0; j < 16; j++) {
if (j == i * 2) bandVal = u_bands[j];
}
float radius = 0.15 + fi * 0.1;
float ringWidth = 0.008 + bandVal * 0.025;
float ring = smoothstep(ringWidth, 0.0, abs(dist - radius - bandVal * 0.05));
// Fade ring by angle sector for variety
float angleFade = 0.5 + 0.5 * sin(angle * (2.0 + fi) + t * (1.0 + fi * 0.3));
rings += ring * angleFade * (0.3 + bandVal * 0.7);
}
// === Layer 4: Subtle noise texture ===
float n = noise(uv * 4.0 + t * 0.5) * 0.03;
// Combine layers
float intensity = waves + pulse + rings * 0.5 + n;
// Color: accent color with varying brightness
vec3 col = u_accent * intensity;
// Subtle secondary hue shift for depth
vec3 shifted = u_accent.gbr; // Rotated accent
col += shifted * rings * 0.15;
// Vignette
float vignette = 1.0 - smoothstep(0.3, 1.2, dist);
col *= vignette;
// Blend over page background
col = clamp(col, 0.0, 1.0);
float colBright = (col.r + col.g + col.b) / 3.0;
float bgLum = dot(u_bgColor, vec3(0.299, 0.587, 0.114));
// Dark bg: add accent light. Light bg: tint white toward accent via multiply.
vec3 darkResult = u_bgColor + col;
vec3 lightResult = u_bgColor * mix(vec3(1.0), u_accent, colBright * 2.0);
vec3 finalColor = clamp(mix(darkResult, lightResult, bgLum), 0.0, 1.0);
gl_FragColor = vec4(finalColor, 1.0);
}
`;
// ---- WebGL setup ----
function initBackgroundGL() {
bgCanvas = document.getElementById('bg-shader-canvas');
if (!bgCanvas) return false;
bgGL = bgCanvas.getContext('webgl', { alpha: false, antialias: false, depth: false, stencil: false });
if (!bgGL) {
console.warn('WebGL not available for background shader');
return false;
}
const gl = bgGL;
// Compile shaders
const vs = gl.createShader(gl.VERTEX_SHADER);
gl.shaderSource(vs, BG_VERT_SRC);
gl.compileShader(vs);
if (!gl.getShaderParameter(vs, gl.COMPILE_STATUS)) {
console.error('BG vertex shader:', gl.getShaderInfoLog(vs));
return false;
}
const fs = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(fs, BG_FRAG_SRC);
gl.compileShader(fs);
if (!gl.getShaderParameter(fs, gl.COMPILE_STATUS)) {
console.error('BG fragment shader:', gl.getShaderInfoLog(fs));
return false;
}
bgProgram = gl.createProgram();
gl.attachShader(bgProgram, vs);
gl.attachShader(bgProgram, fs);
gl.linkProgram(bgProgram);
if (!gl.getProgramParameter(bgProgram, gl.LINK_STATUS)) {
console.error('BG program link:', gl.getProgramInfoLog(bgProgram));
return false;
}
// Fullscreen quad
const buf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
-1, -1, 1, -1, -1, 1,
-1, 1, 1, -1, 1, 1
]), gl.STATIC_DRAW);
const aPos = gl.getAttribLocation(bgProgram, 'a_position');
gl.enableVertexAttribArray(aPos);
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
gl.useProgram(bgProgram);
// Cache uniform locations once (avoids per-frame lookups)
bgUniforms = {
resolution: gl.getUniformLocation(bgProgram, 'u_resolution'),
time: gl.getUniformLocation(bgProgram, 'u_time'),
bass: gl.getUniformLocation(bgProgram, 'u_bass'),
bands: gl.getUniformLocation(bgProgram, 'u_bands'),
accent: gl.getUniformLocation(bgProgram, 'u_accent'),
bgColor: gl.getUniformLocation(bgProgram, 'u_bgColor'),
};
bgStartTime = performance.now() / 1000;
updateBackgroundColors();
resizeBackgroundCanvas();
window.addEventListener('resize', resizeBackgroundCanvas);
return true;
}
function resizeBackgroundCanvas() {
if (!bgCanvas) return;
const dpr = Math.min(window.devicePixelRatio || 1, 1.5); // Cap DPR for performance
const w = Math.floor(window.innerWidth * dpr);
const h = Math.floor(window.innerHeight * dpr);
if (bgCanvas.width !== w || bgCanvas.height !== h) {
bgCanvas.width = w;
bgCanvas.height = h;
}
}
// ---- Cached color/theme updates (called on accent or theme change, not per-frame) ----
export function updateBackgroundColors() {
const style = getComputedStyle(document.documentElement);
const accentHex = style.getPropertyValue('--accent').trim();
if (accentHex && accentHex.length >= 7) {
bgAccentRGB[0] = parseInt(accentHex.slice(1, 3), 16) / 255;
bgAccentRGB[1] = parseInt(accentHex.slice(3, 5), 16) / 255;
bgAccentRGB[2] = parseInt(accentHex.slice(5, 7), 16) / 255;
}
const bgHex = style.getPropertyValue('--bg-primary').trim();
if (bgHex && bgHex.length >= 7) {
bgBgColorRGB[0] = parseInt(bgHex.slice(1, 3), 16) / 255;
bgBgColorRGB[1] = parseInt(bgHex.slice(3, 5), 16) / 255;
bgBgColorRGB[2] = parseInt(bgHex.slice(5, 7), 16) / 255;
}
}
// ---- 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;
// 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).
// Backend may send float bins (legacy) or int×1000 (new); .scale tells us which.
if (frequencyData && frequencyData.frequencies) {
const bins = frequencyData.frequencies;
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++) {
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) * scale;
bgSmoothedBass += (targetBass - bgSmoothedBass) * (1 - BG_SMOOTHING);
} else {
// Gentle decay when no audio
for (let i = 0; i < BG_BAND_COUNT; i++) {
bgSmoothedBands[i] *= 0.95;
}
bgSmoothedBass *= 0.95;
}
// Set uniforms (locations cached at init, colors cached on change)
gl.uniform1f(bgUniforms.time, time);
gl.uniform1f(bgUniforms.bass, bgSmoothedBass);
gl.uniform1fv(bgUniforms.bands, bgSmoothedBands);
gl.uniform3f(bgUniforms.accent, bgAccentRGB[0], bgAccentRGB[1], bgAccentRGB[2]);
gl.uniform3f(bgUniforms.bgColor, bgBgColorRGB[0], bgBgColorRGB[1], bgBgColorRGB[2]);
gl.drawArrays(gl.TRIANGLES, 0, 6);
}
function startBackground() {
if (bgAnimFrame) return;
if (!bgGL && !initBackgroundGL()) return;
bgCanvas.classList.add('visible');
document.body.classList.add('dynamic-bg-active');
renderBackgroundFrame();
}
function stopBackground() {
if (bgAnimFrame) {
cancelAnimationFrame(bgAnimFrame);
bgAnimFrame = null;
}
if (bgCanvas) {
bgCanvas.classList.remove('visible');
}
document.body.classList.remove('dynamic-bg-active');
}
// ---- Public API ----
export function toggleDynamicBackground() {
bgEnabled = !bgEnabled;
localStorage.setItem('dynamicBackground', bgEnabled);
applyDynamicBackground();
}
export function applyDynamicBackground() {
const btn = document.getElementById('bgToggle');
if (bgEnabled) {
startBackground();
if (btn) btn.classList.add('active');
} else {
stopBackground();
if (btn) btn.classList.remove('active');
}
}