diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..602f9fb
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,23 @@
+# Use Bun canary on Alpine
+FROM oven/bun:canary-alpine
+
+# Set working directory
+WORKDIR /app
+
+# Copy package files first for caching
+COPY package.json bun.lock ./
+
+# Install all dependencies (including devDeps)
+RUN bun install
+
+# Copy the rest of the project
+COPY . .
+
+# Build the project
+RUN bun run build
+
+# Expose Vite preview port
+EXPOSE 4173
+
+# Run the built project
+CMD ["bun", "run", "preview", "--", "--host", "0.0.0.0"]
diff --git a/index.html b/index.html
index 8f4329a..7c06a05 100644
--- a/index.html
+++ b/index.html
@@ -1908,6 +1908,17 @@
+
Visualizer Mode
diff --git a/js/settings.js b/js/settings.js
index 7d1cb45..1ce4ec4 100644
--- a/js/settings.js
+++ b/js/settings.js
@@ -452,12 +452,14 @@ export function initializeSettings(scrobbler, player, api, ui) {
const visualizerModeSetting = document.getElementById('visualizer-mode-setting');
const visualizerSmartIntensitySetting = document.getElementById('visualizer-smart-intensity-setting');
const visualizerSensitivitySetting = document.getElementById('visualizer-sensitivity-setting');
+ const visualizerPresetSetting = document.getElementById('visualizer-preset-setting');
const updateVisualizerSettingsVisibility = (enabled) => {
const display = enabled ? 'flex' : 'none';
if (visualizerModeSetting) visualizerModeSetting.style.display = display;
if (visualizerSmartIntensitySetting) visualizerSmartIntensitySetting.style.display = display;
if (visualizerSensitivitySetting) visualizerSensitivitySetting.style.display = display;
+ if (visualizerPresetSetting) visualizerPresetSetting.style.display = display;
};
if (visualizerEnabledToggle) {
@@ -470,6 +472,22 @@ export function initializeSettings(scrobbler, player, api, ui) {
});
}
+ // Visualizer Preset Select
+ const visualizerPresetSelect = document.getElementById('visualizer-preset-select');
+ if (visualizerPresetSelect) {
+ visualizerPresetSelect.value = visualizerSettings.getPreset();
+ visualizerPresetSelect.addEventListener('change', (e) => {
+ const val = e.target.value;
+ visualizerSettings.setPreset(val);
+ // Assuming 'ui' has access to 'visualizer' instance or we need to find it
+ // 'ui' is passed to initializeSettings.
+ // In ui.js, 'visualizer' is a property of UIRenderer.
+ if (ui && ui.visualizer) {
+ ui.visualizer.setPreset(val);
+ }
+ });
+ }
+
// Visualizer Mode Select
const visualizerModeSelect = document.getElementById('visualizer-mode-select');
if (visualizerModeSelect) {
diff --git a/js/storage.js b/js/storage.js
index 96efb2a..beb95c3 100644
--- a/js/storage.js
+++ b/js/storage.js
@@ -627,6 +627,19 @@ export const visualizerSettings = {
SMART_INTENSITY_KEY: 'visualizer-smart-intensity',
ENABLED_KEY: 'visualizer-enabled',
MODE_KEY: 'visualizer-mode', // 'solid' or 'blended'
+ PRESET_KEY: 'visualizer-preset',
+
+ getPreset() {
+ try {
+ return localStorage.getItem(this.PRESET_KEY) || 'lcd';
+ } catch {
+ return 'lcd';
+ }
+ },
+
+ setPreset(preset) {
+ localStorage.setItem(this.PRESET_KEY, preset);
+ },
isEnabled() {
try {
diff --git a/js/visualizer.js b/js/visualizer.js
index 0bab0da..d53ec94 100644
--- a/js/visualizer.js
+++ b/js/visualizer.js
@@ -1,23 +1,54 @@
-//js/visualizer.js
+// js/visualizer.js
import { visualizerSettings } from './storage.js';
+import { LCDPreset } from './visualizers/lcd.js';
+import { ParticlesPreset } from './visualizers/particles.js';
+import { UnknownPleasuresPreset } from './visualizers/unknown_pleasures.js';
export class Visualizer {
constructor(canvas, audio) {
this.canvas = canvas;
- this.ctx = canvas.getContext('2d');
+ this.ctx = null;
this.audio = audio;
+
this.audioContext = null;
this.analyser = null;
this.source = null;
+
this.isActive = false;
this.animationId = null;
- this.particles = [];
- this.kick = 0;
- this.lastIntensity = 0;
- this.lastBeatTime = 0;
- this.energyAverage = 0.3;
- this.upbeatSmoother = 0;
+ this.presets = {
+ lcd: new LCDPreset(),
+ particles: new ParticlesPreset(),
+ 'unknown-pleasures': new UnknownPleasuresPreset(),
+ };
+
+ this.activePresetKey = visualizerSettings.getPreset();
+
+ // ---- AUDIO BUFFERS (REUSED) ----
+ this.bufferLength = 0;
+ this.dataArray = null;
+
+ // ---- STATS (REUSED OBJECT) ----
+ this.stats = {
+ kick: 0,
+ intensity: 0,
+ energyAverage: 0.3,
+ lastBeatTime: 0,
+ lastIntensity: 0,
+ upbeatSmoother: 0,
+ sensitivity: 0.5,
+ primaryColor: '#ffffff',
+ mode: '',
+ };
+
+ // ---- CACHED STATE ----
+ this._lastPrimaryColor = '';
+ this._resizeBound = () => this.resize();
+ }
+
+ get activePreset() {
+ return this.presets[this.activePresetKey] || this.presets['lcd'];
}
init() {
@@ -26,210 +57,165 @@ export class Visualizer {
try {
const AudioContext = window.AudioContext || window.webkitAudioContext;
this.audioContext = new AudioContext();
+
this.analyser = this.audioContext.createAnalyser();
this.analyser.fftSize = 512;
this.analyser.smoothingTimeConstant = 0.7;
+ this.bufferLength = this.analyser.frequencyBinCount;
+ this.dataArray = new Uint8Array(this.bufferLength);
+
this.source = this.audioContext.createMediaElementSource(this.audio);
this.source.connect(this.analyser);
this.analyser.connect(this.audioContext.destination);
} catch (e) {
- console.warn('Visualizer init failed (likely CORS or already connected):', e);
+ console.warn('Visualizer init failed:', e);
+ }
+ }
+
+ initContext() {
+ if (this.ctx) return;
+
+ const preset = this.activePreset;
+ const type = preset.contextType || '2d';
+
+ if (type === 'webgl') {
+ this.ctx =
+ this.canvas.getContext('webgl2', { alpha: true, antialias: false }) ||
+ this.canvas.getContext('webgl', { alpha: true, antialias: false });
+ } else {
+ this.ctx = this.canvas.getContext('2d');
}
}
start() {
if (this.isActive) return;
+
+ if (!this.ctx) this.initContext();
if (!this.audioContext) this.init();
if (!this.analyser) return;
this.isActive = true;
+
if (this.audioContext.state === 'suspended') {
this.audioContext.resume();
}
this.resize();
- window.addEventListener('resize', this.resizeBound);
+ window.addEventListener('resize', this._resizeBound);
this.canvas.style.display = 'block';
- this.particles = [];
- this.energyAverage = 0.3;
- this.kick = 0;
- this.upbeatSmoother = 0;
this.animate();
}
stop() {
this.isActive = false;
+
if (this.animationId) {
cancelAnimationFrame(this.animationId);
this.animationId = null;
}
- window.removeEventListener('resize', this.resizeBound);
- this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
+ window.removeEventListener('resize', this._resizeBound);
+
+ if (this.ctx && this.ctx.clearRect) {
+ this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
+ }
+
this.canvas.style.display = 'none';
}
resize() {
- this.canvas.width = window.innerWidth;
- this.canvas.height = window.innerHeight;
+ const w = window.innerWidth;
+ const h = window.innerHeight;
+
+ if (this.canvas.width !== w) this.canvas.width = w;
+ if (this.canvas.height !== h) this.canvas.height = h;
+
+ if (this.activePreset?.resize) {
+ this.activePreset.resize(w, h);
+ }
}
- resizeBound = () => this.resize();
-
- animate() {
+ animate = () => {
if (!this.isActive) return;
- this.animationId = requestAnimationFrame(() => this.animate());
+ this.animationId = requestAnimationFrame(this.animate);
- const w = this.canvas.width;
- const h = this.canvas.height;
- const ctx = this.ctx;
+ // ===== AUDIO ANALYSIS =====
+ this.analyser.getByteFrequencyData(this.dataArray);
- let sensitivity = visualizerSettings.getSensitivity();
- const mode = visualizerSettings.getMode();
- const isDark = document.documentElement.getAttribute('data-theme') !== 'light';
+ // Bass (first bins only — cheap)
+ let bass = (this.dataArray[0] + this.dataArray[1] + this.dataArray[2] + this.dataArray[3]) * 0.000980392; // 1 / (4 * 255)
- if (mode === 'blended') {
- ctx.clearRect(0, 0, w, h);
- } else {
- // Match background to theme if in solid mode
- if (isDark) {
- ctx.fillStyle = 'rgba(10, 10, 10, 0.3)';
- } else {
- ctx.fillStyle = 'rgba(240, 240, 240, 0.3)';
- }
- ctx.fillRect(0, 0, w, h);
- }
-
- const bufferLength = this.analyser.frequencyBinCount;
- const dataArray = new Uint8Array(bufferLength);
- this.analyser.getByteFrequencyData(dataArray);
-
- let bassSum = 0;
- for (let i = 0; i < 4; i++) bassSum += dataArray[i];
- const bass = bassSum / 4 / 255;
const intensity = bass * bass;
+ const stats = this.stats;
- this.energyAverage = this.energyAverage * 0.99 + intensity * 0.01;
- this.upbeatSmoother = this.upbeatSmoother * 0.92 + intensity * 0.08;
+ stats.energyAverage = stats.energyAverage * 0.99 + intensity * 0.01;
+ stats.upbeatSmoother = stats.upbeatSmoother * 0.92 + intensity * 0.08;
+ // ===== SENSITIVITY =====
+ let sensitivity = visualizerSettings.getSensitivity();
if (visualizerSettings.isSmartIntensityEnabled()) {
- let target = 0.1;
- if (this.energyAverage > 0.4) {
- target = 0.7;
- } else if (this.energyAverage > 0.2) {
- const t = (this.energyAverage - 0.2) / 0.2;
- target = 0.1 + t * 0.6;
- }
- sensitivity = target;
- }
-
- let threshold = 0.5;
- if (this.energyAverage < 0.3) {
- threshold = 0.5 + (0.3 - this.energyAverage) * 2;
- }
-
- const now = Date.now();
- if (intensity > threshold) {
- if (intensity > this.lastIntensity + 0.05 && now - this.lastBeatTime > 50) {
- this.kick = 1.0;
- this.lastBeatTime = now;
+ if (stats.energyAverage > 0.4) {
+ sensitivity = 0.7;
+ } else if (stats.energyAverage > 0.2) {
+ sensitivity = 0.1 + ((stats.energyAverage - 0.2) / 0.2) * 0.6;
} else {
- if (this.upbeatSmoother > 0.6 && this.energyAverage > 0.4) {
- const upbeatLevel = (this.upbeatSmoother - 0.6) / 0.4;
- if (this.kick < upbeatLevel) {
- this.kick = upbeatLevel;
+ sensitivity = 0.1;
+ }
+ }
+
+ // ===== KICK DETECTION =====
+ const now = performance.now();
+ let threshold = stats.energyAverage < 0.3 ? 0.5 + (0.3 - stats.energyAverage) * 2 : 0.5;
+
+ if (intensity > threshold) {
+ if (intensity > stats.lastIntensity + 0.05 && now - stats.lastBeatTime > 50) {
+ stats.kick = 1.0;
+ stats.lastBeatTime = now;
+ } else {
+ if (stats.upbeatSmoother > 0.6 && stats.energyAverage > 0.4) {
+ const upbeatLevel = (stats.upbeatSmoother - 0.6) / 0.4;
+ if (stats.kick < upbeatLevel) {
+ stats.kick = upbeatLevel;
} else {
- this.kick *= 0.95;
+ stats.kick *= 0.95;
}
} else {
- this.kick *= 0.9;
+ stats.kick *= 0.9;
}
}
} else {
- this.kick *= 0.95;
- }
- this.lastIntensity = intensity;
-
- let shakeX = 0;
- let shakeY = 0;
- if (this.kick > 0.1) {
- const shakeAmt = this.kick * 8 * sensitivity;
- shakeX = (Math.random() - 0.5) * shakeAmt;
- shakeY = (Math.random() - 0.5) * shakeAmt;
+ stats.kick *= 0.95;
}
- const primaryColor =
- getComputedStyle(document.documentElement).getPropertyValue('--primary').trim() || '#ffffff';
+ stats.lastIntensity = intensity;
+ stats.intensity = intensity;
+ stats.sensitivity = sensitivity;
- const particleCount = 180;
- if (this.particles.length !== particleCount) {
- this.particles = [];
- for (let i = 0; i < particleCount; i++) {
- this.particles.push({
- x: Math.random() * w,
- y: Math.random() * h,
- vx: (Math.random() - 0.5) * 2,
- vy: (Math.random() - 0.5) * 2,
- baseSize: Math.random() * 3 + 1,
- });
- }
+ // ===== COLORS (CACHED) =====
+ const color = getComputedStyle(document.documentElement).getPropertyValue('--primary').trim() || '#ffffff';
+
+ if (color !== this._lastPrimaryColor) {
+ stats.primaryColor = color;
+ this._lastPrimaryColor = color;
}
- ctx.save();
- ctx.translate(shakeX, shakeY);
+ stats.mode = visualizerSettings.getMode();
- ctx.fillStyle = primaryColor;
- ctx.strokeStyle = primaryColor;
+ // ===== DRAW =====
+ this.activePreset.draw(this.ctx, this.canvas, this.analyser, this.dataArray, stats);
+ };
- const maxDist = 150 + intensity * 50 + this.kick * 50 * sensitivity;
- const maxDistSq = maxDist * maxDist;
+ setPreset(key) {
+ if (!this.presets[key]) return;
- for (let i = 0; i < this.particles.length; i++) {
- let p = this.particles[i];
-
- const speedMult = 1 + intensity * 2 + this.kick * 8 * sensitivity;
- p.x += p.vx * speedMult;
- p.y += p.vy * speedMult;
-
- if (this.kick > 0.3) {
- p.x += (Math.random() - 0.5) * this.kick * 2 * sensitivity;
- p.y += (Math.random() - 0.5) * this.kick * 2 * sensitivity;
- }
-
- if (p.x < 0) p.x = w;
- if (p.x > w) p.x = 0;
- if (p.y < 0) p.y = h;
- if (p.y > h) p.y = 0;
-
- const size = p.baseSize * (1 + intensity * 0.5 + this.kick * 0.8 * sensitivity);
- ctx.globalAlpha = 0.4 + intensity * 0.2 + this.kick * 0.15 * sensitivity;
- ctx.beginPath();
- ctx.arc(p.x, p.y, size, 0, Math.PI * 2);
- ctx.fill();
-
- for (let j = i + 1; j < this.particles.length; j++) {
- const p2 = this.particles[j];
- const dx = p.x - p2.x;
- const dy = p.y - p2.y;
-
- // Optimization: Early exit for x distance
- if (Math.abs(dx) > maxDist) continue;
-
- const distSq = dx * dx + dy * dy;
-
- if (distSq < maxDistSq) {
- const dist = Math.sqrt(distSq); // Still need dist for alpha/linewidth, but now we only sqrt when necessary
- ctx.beginPath();
- ctx.lineWidth = (1 - dist / maxDist) * (1 + this.kick * 1.5 * sensitivity);
- ctx.globalAlpha = (1 - dist / maxDist) * (0.3 + intensity * 0.2 + this.kick * 0.3 * sensitivity);
- ctx.moveTo(p.x, p.y);
- ctx.lineTo(p2.x, p2.y);
- ctx.stroke();
- }
- }
+ if (this.activePreset?.destroy) {
+ this.activePreset.destroy();
}
- ctx.restore();
+
+ this.activePresetKey = key;
+ this.initContext();
+ this.resize();
}
}
diff --git a/js/visualizers/lcd.js b/js/visualizers/lcd.js
new file mode 100644
index 0000000..492194c
--- /dev/null
+++ b/js/visualizers/lcd.js
@@ -0,0 +1,386 @@
+export class LCDPreset {
+ constructor() {
+ this.name = 'LCD Pixels';
+ this.gridCols = 48;
+
+ // Auto-gain tracking
+ this.maxVol = 100;
+ this.volDecay = 0.995;
+
+ // Smoothing state
+ this.prevData = new Float32Array(this.gridCols).fill(0);
+ this.peakData = new Float32Array(this.gridCols).fill(0);
+
+ this.primaryColor = '#ffffff';
+ this.disableShake = false;
+
+ // WebGL grid overlay
+ this.glCanvas = null;
+ this.gl = null;
+ this.glProgram = null;
+ this.glInitialized = false;
+ }
+
+ // Initialize WebGL grid overlay
+ initWebGL(width, height) {
+ if (this.glInitialized) return;
+
+ // Create overlay canvas
+ this.glCanvas = document.createElement('canvas');
+ this.glCanvas.width = width;
+ this.glCanvas.height = height;
+ this.glCanvas.style.cssText = 'position:absolute;top:0;left:0;pointer-events:none;mix-blend-mode:multiply;';
+
+ const gl = this.glCanvas.getContext('webgl', { alpha: true, premultipliedAlpha: false });
+ if (!gl) {
+ console.warn('WebGL not available for grid overlay');
+ return;
+ }
+ this.gl = gl;
+
+ // Vertex shader (fullscreen quad)
+ const vsSource = `
+ attribute vec2 a_position;
+ varying vec2 v_uv;
+ void main() {
+ v_uv = a_position * 0.5 + 0.5;
+ gl_Position = vec4(a_position, 0.0, 1.0);
+ }
+ `;
+
+ // Fragment shader (LCD dot matrix with tilt-shift blur)
+ const fsSource = `
+ precision highp float;
+ varying vec2 v_uv;
+ uniform vec2 u_resolution;
+ uniform float u_time;
+
+ float hash(vec2 p) {
+ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
+ }
+
+ void main() {
+ vec2 uv = v_uv;
+ float aspect = u_resolution.x / u_resolution.y;
+
+ // Skew transform
+ vec2 centered = uv - 0.5;
+ mat2 skewMatrix = mat2(1.0, 0.0, 0.20, 1.0);
+ vec2 skewed = skewMatrix * centered + 0.5;
+
+ // Perspective: shrink towards right
+ float perspT = skewed.x;
+ float perspScale = mix(1.0, 0.5, perspT);
+
+ // Tilt-shift: focus at 25%, blur both near (left) and far (right)
+ float focusPoint = 0.25;
+ float distFromFocus = abs(perspT - focusPoint);
+ float blurAmount = smoothstep(0.0, 0.6, distFromFocus);
+
+ // Apply perspective
+ vec2 pUV = skewed;
+ pUV.y = (pUV.y - 0.5) * perspScale + 0.5;
+ pUV.x *= aspect;
+
+ // Dot matrix grid
+ float cellSize = 0.0078 * perspScale;
+ vec2 gridUV = pUV / cellSize;
+ vec2 gv = fract(gridUV) - 0.5;
+ vec2 id = floor(gridUV);
+
+ float d = length(gv);
+ float dotRadius = 0.35;
+
+ // Dot edge with blur (pattern stays visible)
+ float sharpness = mix(0.08, 0.25, blurAmount);
+ float dotEdge = smoothstep(dotRadius - sharpness, dotRadius + sharpness * 0.3, d);
+
+ // Per-cell noise
+ float noise = hash(id);
+ dotEdge *= 0.75 + noise * 0.25;
+
+ // Subtle grain
+ float grain = hash(uv * u_resolution + u_time) * 0.015;
+
+ // Output
+ float alpha = clamp(dotEdge * 0.5 + grain, 0.0, 0.5);
+ gl_FragColor = vec4(0.0, 0.0, 0.0, alpha);
+ }
+ `;
+
+ // Compile shaders
+ const vs = this.compileShader(gl, gl.VERTEX_SHADER, vsSource);
+ const fs = this.compileShader(gl, gl.FRAGMENT_SHADER, fsSource);
+ if (!vs || !fs) return;
+
+ // Link program
+ const program = gl.createProgram();
+ gl.attachShader(program, vs);
+ gl.attachShader(program, fs);
+ gl.linkProgram(program);
+
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
+ console.error('Shader program failed to link');
+ return;
+ }
+
+ this.glProgram = program;
+
+ // Create fullscreen quad
+ const vertices = new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]);
+ const buffer = gl.createBuffer();
+ gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
+ gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
+
+ const posLoc = gl.getAttribLocation(program, 'a_position');
+ gl.enableVertexAttribArray(posLoc);
+ gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0);
+
+ // Store uniform locations
+ this.uResolution = gl.getUniformLocation(program, 'u_resolution');
+ this.uTime = gl.getUniformLocation(program, 'u_time');
+
+ gl.useProgram(program);
+ gl.enable(gl.BLEND);
+ gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
+
+ this.startTime = performance.now();
+ this.glInitialized = true;
+ }
+
+ compileShader(gl, type, source) {
+ const shader = gl.createShader(type);
+ gl.shaderSource(shader, source);
+ gl.compileShader(shader);
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
+ console.error('Shader compile error:', gl.getShaderInfoLog(shader));
+ gl.deleteShader(shader);
+ return null;
+ }
+ return shader;
+ }
+
+ // Render WebGL grid overlay
+ renderHoneycomb(width, height) {
+ if (!this.gl || !this.glProgram) return;
+
+ const gl = this.gl;
+
+ // Resize if needed
+ if (this.glCanvas.width !== width || this.glCanvas.height !== height) {
+ this.glCanvas.width = width;
+ this.glCanvas.height = height;
+ gl.viewport(0, 0, width, height);
+ }
+
+ gl.clearColor(0, 0, 0, 0);
+ gl.clear(gl.COLOR_BUFFER_BIT);
+
+ // Pass uniforms
+ gl.uniform2f(this.uResolution, width, height);
+ gl.uniform1f(this.uTime, (performance.now() - this.startTime) / 1000.0);
+
+ gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
+ }
+
+ resize() {}
+
+ draw(ctx, canvas, analyser, dataArray, params) {
+ const { width, height } = canvas;
+ const { kick, intensity, primaryColor, mode } = params;
+
+ this.primaryColor = primaryColor;
+ const isDark = document.documentElement.getAttribute('data-theme') !== 'light';
+
+ // --- Background ---
+ ctx.clearRect(0, 0, width, height);
+ if (mode !== 'blended') {
+ ctx.fillStyle = isDark ? '#050505' : '#e6e6e6';
+ ctx.fillRect(0, 0, width, height);
+ }
+
+ // --- Audio Data Processing ---
+ const data = this.processAudio(dataArray);
+
+ // --- Perspective Constants ---
+ const centerX = width / 2;
+ const centerY = height * 0.35;
+ const startX = width * 0.05;
+ const endX = width * 0.95;
+ const totalW = endX - startX;
+ const maxBarH = height;
+ const startScale = 2.0; // Left (near) - increased
+ const endScale = 0.05; // Right (far) - decreased
+
+ // --- Apply Global Skew Transform ---
+ ctx.save();
+ ctx.translate(centerX, centerY);
+ ctx.transform(1, -0.08, 0.2, 1, 0, 0);
+ ctx.translate(-centerX, -centerY);
+
+ // Shake on kick
+ if (!this.disableShake && kick > 0.3) {
+ const shake = kick * 40;
+ ctx.translate((Math.random() - 0.5) * 2 * shake, (Math.random() - 0.5) * shake);
+ }
+
+ // --- Draw Bars ---
+ const baseBarW = (totalW / this.gridCols) * 0.7; // Base width
+
+ for (let c = 0; c < this.gridCols; c++) {
+ const p = c / (this.gridCols - 1);
+
+ // Simple perspective: scale goes from startScale (left) to endScale (right)
+ const scale = startScale + (endScale - startScale) * p;
+
+ // Perspective spacing: gaps decrease linearly matching the scale
+ // Integral of linear scale function, normalized to 0-1
+ const scaleDelta = endScale - startScale;
+ const pIntegral = startScale * p + 0.5 * scaleDelta * p * p;
+ const totalIntegral = startScale + 0.5 * scaleDelta; // Value at p=1
+ const pPerspective = pIntegral / totalIntegral;
+ const cx = startX + pPerspective * totalW;
+
+ // Width scales with perspective
+ const barW = baseBarW * scale;
+
+ // Bar height - skip empty bars entirely
+ const normVal = data[c];
+ if (normVal < 0.01) continue;
+
+ const h = normVal * maxBarH * scale;
+ if (h < 1) continue;
+
+ // Per-bar color variation
+ const variation = 0.75 + Math.abs(Math.sin(c * 127.1)) * 0.25;
+ ctx.fillStyle = this.adjustBrightness(primaryColor, variation);
+
+ // Strong LCD light bleed effect
+ ctx.shadowBlur = 30 + normVal * 50; // Increased glow
+ ctx.shadowColor = primaryColor;
+ this.drawCapsule(ctx, cx, centerY, barW, h);
+ }
+
+ ctx.restore();
+
+ // --- WebGL grid Overlay ---
+ // Initialize on first run
+ if (!this.glInitialized) {
+ this.initWebGL(width, height);
+ // Attach WebGL canvas to same parent as main canvas
+ if (this.glCanvas && canvas.parentElement) {
+ canvas.parentElement.style.position = 'relative';
+ canvas.parentElement.appendChild(this.glCanvas);
+ }
+ }
+
+ // Render and composite grid
+ this.renderHoneycomb(width, height);
+ }
+
+ // Process audio with improved dynamics
+ processAudio(dataArray) {
+ const result = new Float32Array(this.gridCols);
+ const center = Math.floor(this.gridCols / 2);
+ const totalBins = dataArray.length;
+ let peakVal = 0;
+
+ for (let i = 0; i < center; i++) {
+ const p = i / (center - 1);
+
+ // Logarithmic frequency mapping
+ const minBin = 2;
+ const maxBin = totalBins * 0.65;
+ const startBin = Math.floor(minBin * Math.pow(maxBin / minBin, p));
+ const endBin = Math.max(startBin + 1, Math.floor(minBin * Math.pow(maxBin / minBin, p + 1 / center)));
+
+ let sum = 0,
+ count = 0;
+ for (let k = startBin; k < endBin && k < totalBins; k++) {
+ sum += dataArray[k];
+ count++;
+ }
+ let val = count > 0 ? sum / count : 0;
+
+ // Pink noise compensation (boost highs)
+ val *= 1 + p * 1.8;
+ if (val > peakVal) peakVal = val;
+
+ // Mirror to left/right
+ const leftIdx = center - 1 - i;
+ const rightIdx = center + i;
+
+ // Smooth with asymmetric rise/fall
+ const rise = 0.25;
+ const fall = 0.08; // Slower fall for smoother decay
+
+ for (const idx of [leftIdx, rightIdx]) {
+ const prev = this.prevData[idx];
+ const target = val;
+ this.prevData[idx] = prev + (target - prev) * (target > prev ? rise : fall);
+ }
+ }
+
+ // Auto-gain with more headroom
+ this.maxVol = Math.max(this.maxVol * this.volDecay, peakVal, 40);
+ const normFactor = 200 / this.maxVol;
+
+ // Normalize and apply contrast curve
+ for (let c = 0; c < this.gridCols; c++) {
+ let v = (this.prevData[c] * normFactor) / 255;
+
+ // Noise gate: important to scale the bars
+ const gate = 0.5;
+ if (v < gate) v = 0;
+ else v = (v - gate) / (1 - gate);
+
+ // Soft compression + contrast
+ v = Math.pow(Math.min(1, v), 2.2);
+ result[c] = v;
+ }
+
+ return result;
+ }
+
+ // Draw rounded capsule shape
+ drawCapsule(ctx, cx, cy, w, h) {
+ if (h < w) {
+ ctx.beginPath();
+ ctx.arc(cx, cy, Math.max(0.5, h / 2), 0, Math.PI * 2);
+ ctx.fill();
+ return;
+ }
+ const halfH = h / 2;
+ const r = w / 2;
+ ctx.beginPath();
+ ctx.arc(cx, cy - halfH + r, r, Math.PI, 0);
+ ctx.lineTo(cx + r, cy + halfH - r);
+ ctx.arc(cx, cy + halfH - r, r, 0, Math.PI);
+ ctx.lineTo(cx - r, cy - halfH + r);
+ ctx.closePath();
+ ctx.fill();
+ }
+
+ // Adjust hex color brightness
+ adjustBrightness(hex, factor) {
+ const r = parseInt(hex.slice(1, 3), 16);
+ const g = parseInt(hex.slice(3, 5), 16);
+ const b = parseInt(hex.slice(5, 7), 16);
+ const clamp = (v) => Math.min(255, Math.max(0, Math.round(v * factor)));
+ return `rgb(${clamp(r)},${clamp(g)},${clamp(b)})`;
+ }
+
+ destroy() {
+ if (this.glCanvas) {
+ this.glCanvas.remove();
+ this.glCanvas = null;
+ }
+ if (this.gl) {
+ const ext = this.gl.getExtension('WEBGL_lose_context');
+ if (ext) ext.loseContext();
+ this.gl = null;
+ }
+ this.glInitialized = false;
+ this.glProgram = null;
+ }
+}
diff --git a/js/visualizers/particles.js b/js/visualizers/particles.js
new file mode 100644
index 0000000..bbe90a0
--- /dev/null
+++ b/js/visualizers/particles.js
@@ -0,0 +1,102 @@
+export class ParticlesPreset {
+ constructor() {
+ this.name = 'Particles';
+ this.particles = [];
+ this.particleCount = 180;
+ }
+
+ resize(width, height) {
+ // Particles don't need explicit resize logic unless we want to respawn them,
+ // but current logic handles boundaries in draw loop.
+ }
+
+ destroy() {
+ // No cleanup needed
+ }
+
+ draw(ctx, canvas, analyser, dataArray, params) {
+ const { width, height } = canvas;
+ const { kick, intensity, primaryColor } = params;
+ const sensitivity = params.sensitivity || 1.0;
+
+ // Clear background
+ ctx.clearRect(0, 0, width, height);
+
+ // Manage particle count
+ if (this.particles.length !== this.particleCount) {
+ this.particles = [];
+ for (let i = 0; i < this.particleCount; i++) {
+ this.particles.push({
+ x: Math.random() * width,
+ y: Math.random() * height,
+ vx: (Math.random() - 0.5) * 2,
+ vy: (Math.random() - 0.5) * 2,
+ baseSize: Math.random() * 3 + 1,
+ });
+ }
+ }
+
+ ctx.save();
+
+ // Shake
+ let shakeX = 0;
+ let shakeY = 0;
+ if (kick > 0.1) {
+ const shakeAmt = kick * 8 * sensitivity;
+ shakeX = (Math.random() - 0.5) * shakeAmt;
+ shakeY = (Math.random() - 0.5) * shakeAmt;
+ }
+ ctx.translate(shakeX, shakeY);
+
+ ctx.fillStyle = primaryColor;
+ ctx.strokeStyle = primaryColor;
+
+ const maxDist = 150 + intensity * 50 + kick * 50 * sensitivity;
+ const maxDistSq = maxDist * maxDist;
+
+ for (let i = 0; i < this.particles.length; i++) {
+ let p = this.particles[i];
+
+ const speedMult = 1 + intensity * 2 + kick * 8 * sensitivity;
+ p.x += p.vx * speedMult;
+ p.y += p.vy * speedMult;
+
+ if (kick > 0.3) {
+ p.x += (Math.random() - 0.5) * kick * 2 * sensitivity;
+ p.y += (Math.random() - 0.5) * kick * 2 * sensitivity;
+ }
+
+ if (p.x < 0) p.x = width;
+ if (p.x > width) p.x = 0;
+ if (p.y < 0) p.y = height;
+ if (p.y > height) p.y = 0;
+
+ const size = p.baseSize * (1 + intensity * 0.5 + kick * 0.8 * sensitivity);
+ ctx.globalAlpha = 0.4 + intensity * 0.2 + kick * 0.15 * sensitivity;
+ ctx.beginPath();
+ ctx.arc(p.x, p.y, size, 0, Math.PI * 2);
+ ctx.fill();
+
+ for (let j = i + 1; j < this.particles.length; j++) {
+ const p2 = this.particles[j];
+ const dx = p.x - p2.x;
+ const dy = p.y - p2.y;
+
+ if (Math.abs(dx) > maxDist) continue;
+
+ const distSq = dx * dx + dy * dy;
+
+ if (distSq < maxDistSq) {
+ const dist = Math.sqrt(distSq);
+ ctx.beginPath();
+ ctx.lineWidth = (1 - dist / maxDist) * (1 + kick * 1.5 * sensitivity);
+ ctx.globalAlpha = (1 - dist / maxDist) * (0.3 + intensity * 0.2 + kick * 0.3 * sensitivity);
+ ctx.moveTo(p.x, p.y);
+ ctx.lineTo(p2.x, p2.y);
+ ctx.stroke();
+ }
+ }
+ }
+ ctx.restore();
+ }
+}
diff --git a/js/visualizers/unknown_pleasures.js b/js/visualizers/unknown_pleasures.js
new file mode 100644
index 0000000..a17528b
--- /dev/null
+++ b/js/visualizers/unknown_pleasures.js
@@ -0,0 +1,141 @@
+export class UnknownPleasuresPreset {
+ constructor() {
+ this.name = 'Unknown Pleasures';
+ this.historySize = 30;
+ this.dataPoints = 96;
+
+ this.history = [];
+ this.writeIndex = 0;
+
+ this.pLookup = new Float32Array(this.dataPoints);
+ this.xLookup = new Float32Array(this.dataPoints);
+
+ // palette cache
+ this._palette = null;
+ this._paletteColor = '';
+
+ this.reset();
+ this._precompute();
+ }
+
+ reset() {
+ this.history.length = 0;
+ for (let i = 0; i < this.historySize; i++) {
+ this.history.push(new Float32Array(this.dataPoints));
+ }
+ this.writeIndex = 0;
+ }
+
+ resize() {}
+ destroy() {
+ this.history.length = 0;
+ }
+
+ _precompute() {
+ const pts = this.dataPoints;
+ const inv = 1 / (pts - 1);
+ for (let i = 0; i < pts; i++) {
+ const p = Math.abs(i * inv - 0.5) * 2;
+ this.pLookup[i] = 1 - p * p * p;
+ this.xLookup[i] = i * inv;
+ }
+ }
+
+ _buildPalette(color) {
+ const r = parseInt(color.slice(1, 3), 16);
+ const g = parseInt(color.slice(3, 5), 16);
+ const b = parseInt(color.slice(5, 7), 16);
+
+ // perceptual grayscale (same weights browsers use)
+ const gray = 0.299 * r + 0.587 * g + 0.114 * b;
+
+ this._palette = new Array(this.historySize);
+
+ for (let i = 0; i < this.historySize; i++) {
+ const p = i / (this.historySize - 1);
+
+ // === Saturation gradient (HSL-like) ===
+ const sat = 3.0 - 2 * p;
+ const rr = (gray + (r - gray) * sat) | 0;
+ const gg = (gray + (g - gray) * sat) | 0;
+ const bb = (gray + (b - gray) * sat) | 0;
+
+ this._palette[i] = `rgba(${rr},${gg},${bb}, 1.0)`;
+ }
+
+ this._paletteColor = color;
+ }
+
+ draw(ctx, canvas, analyser, dataArray, params) {
+ // Init if empty (e.g. after destroy/switch)
+ if (this.history.length === 0) {
+ this.reset();
+ }
+
+ const pts = this.dataPoints;
+ const len = dataArray.length | 0;
+
+ const line = this.history[this.writeIndex];
+ if (line) {
+ for (let i = 0; i < pts; i++) {
+ line[i] = (dataArray[(this.xLookup[i] * len) | 0] / 255) * this.pLookup[i];
+ }
+ }
+ this.writeIndex = (this.writeIndex + 1) % this.historySize;
+
+ if (this._paletteColor !== params.primaryColor) {
+ this._buildPalette(params.primaryColor);
+ }
+
+ const { width, height } = canvas;
+ ctx.clearRect(0, 0, width, height);
+
+ const size = Math.hypot(width, height) * 1.42;
+
+ ctx.save();
+ ctx.translate((width + size) / 2, height / 2);
+ ctx.rotate(Math.PI / 6);
+ ctx.translate(-(width + size) / 2, -height / 2);
+
+ // SINGLE shadow pass (cheap)
+ ctx.shadowColor = params.primaryColor;
+ ctx.shadowBlur = 32 * (1 + params.kick * 2);
+ ctx.lineJoin = 'round';
+ ctx.shadowOffsetX = params.kick * 10;
+ ctx.shadowOffsetY = params.kick * 10;
+ const horizonY = size * 0.1;
+ const frontY = size * 0.8;
+ const depth = 2.0;
+
+ const totalH = frontY - horizonY;
+ const B = totalH / (1 - 1 / (1 + depth));
+ const A = frontY - B;
+
+ for (let i = this.historySize - 1; i >= 0; i--) {
+ const idx = (this.writeIndex + i) % this.historySize;
+ const data = this.history[idx];
+
+ const p = 1 - i / (this.historySize - 1);
+ const z = 1 + p * depth;
+ const scale = 1 / z;
+ const y = A + B / z;
+
+ ctx.strokeStyle = this._palette[i];
+ ctx.lineWidth = Math.max(1, 8 * scale + params.kick * 6);
+
+ const lw = size * scale * 1.5;
+ const margin = (size - lw) * 0.5;
+ const amp = 200 * scale + params.kick * 100;
+
+ ctx.beginPath();
+ ctx.moveTo(margin, y);
+
+ for (let j = 0; j < pts; j++) {
+ ctx.lineTo(margin + this.xLookup[j] * lw, y - data[j] * amp);
+ }
+ ctx.stroke();
+ }
+
+ ctx.restore();
+ }
+}
diff --git a/styles.css b/styles.css
index 5ea73f5..81d2241 100644
--- a/styles.css
+++ b/styles.css
@@ -2126,10 +2126,12 @@ input:checked + .slider::before {
text-align: center;
z-index: 1;
max-width: 90%;
- background: color-mix(in srgb, var(--card), transparent 80%);
+ background-color: color-mix(in srgb, var(--card) 40%, transparent);
+ backdrop-filter: blur(20px);
padding: 0.75rem 1.5rem;
border-radius: var(--radius);
- border: 1px solid color-mix(in srgb, var(--card), transparent 70%);
+ border: 1px solid var(--border);
+ box-shadow: 0 8px 32px rgb(0, 0, 0, 0.4);
}
#fullscreen-track-title {