Add Visualizer tab to Focus mode with mirror visualization

- Add 'visualizer' to FocusTab type
- Add Visualizer tab with Activity icon in ImmersiveHeader
- Update VisualizerPanel with mirror mode (bars up and down from center)
- Integrate VisualizerPanel in ImmersivePlayer focus view
- Three-color gradient: teal -> purple -> pink
- Track info overlay with artwork, title, artist
This commit is contained in:
vicrodh committed 2026-02-01 15:33:47 -06:00
1 parent 06d3de0d73
commit 337d4866d6
3 files changed
+172 -81

No files matched your search

@@ -1,10 +1,10 @@
<script lang="ts">
import { Disc3, Disc, Mic2, ListMusic, Music2, Info, Radio, Maximize, Minimize, ChevronDown, X, Square, Copy, Minus, Image } from 'lucide-svelte';
import { Disc3, Disc, Mic2, ListMusic, Music2, Info, Radio, Maximize, Minimize, ChevronDown, X, Square, Copy, Minus, Image, Activity } from 'lucide-svelte';
import { t } from '$lib/i18n';
import { getCurrentWindow } from '@tauri-apps/api/window';
export type ImmersiveTab = 'lyrics' | 'trackInfo' | 'suggestions' | 'queue';
export type FocusTab = 'coverflow' | 'static' | 'vinyl' | 'lyrics-focus' | 'queue-focus';
export type FocusTab = 'coverflow' | 'static' | 'vinyl' | 'visualizer' | 'lyrics-focus' | 'queue-focus';
export type ViewMode = 'focus' | 'split';
interface Props {
@@ -87,6 +87,7 @@
{ id: 'coverflow', label: 'Coverflow', icon: Disc3 },
{ id: 'static', label: 'Static', icon: Image },
{ id: 'vinyl', label: 'Vinyl', icon: Disc },
{ id: 'visualizer', label: 'Visualizer', icon: Activity },
{ id: 'lyrics-focus', label: 'Lyrics', icon: Mic2 },
{ id: 'queue-focus', label: 'Queue', icon: ListMusic },
];
@@ -12,6 +12,7 @@
import CoverflowPanel from './panels/CoverflowPanel.svelte';
import StaticPanel from './panels/StaticPanel.svelte';
import VinylPanel from './panels/VinylPanel.svelte';
import VisualizerPanel from './panels/VisualizerPanel.svelte';
import LyricsFocusPanel from './panels/LyricsFocusPanel.svelte';
import QualityBadge from '$lib/components/QualityBadge.svelte';
@@ -365,6 +366,14 @@
{bitDepth}
{samplingRate}
/>
{:else if activeFocusTab === 'visualizer'}
<!-- Visualizer: Audio spectrum with mirror mode -->
<VisualizerPanel
enabled={true}
{artwork}
{trackTitle}
{artist}
/>
{:else if activeFocusTab === 'lyrics-focus'}
<!-- Lyrics Focus: Single line, large, centered -->
<LyricsFocusPanel
@@ -1,13 +1,16 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { onMount } from 'svelte';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/core';
interface Props {
enabled?: boolean;
artwork?: string;
trackTitle?: string;
artist?: string;
}
let { enabled = true }: Props = $props();
let { enabled = true, artwork = '', trackTitle = '', artist = '' }: Props = $props();
let canvasRef: HTMLCanvasElement | null = $state(null);
let gl: WebGL2RenderingContext | null = null;
@@ -16,104 +19,125 @@
let vao: WebGLVertexArrayObject | null = null;
let animationFrame: number | null = null;
let unlisten: UnlistenFn | null = null;
let isInitialized = false;
const NUM_BARS = 64;
const frequencyData = new Float32Array(NUM_BARS);
// Vertex shader - instanced bar rendering
// Vertex shader - MIRROR mode: bars extend from center up and down
const vertexSource = `#version 300 es
precision highp float;
// Per-vertex (quad corners)
in vec2 a_position;
in float a_mirror; // 0 = top half, 1 = bottom half (mirrored)
// Uniforms
uniform sampler2D u_frequencies;
uniform vec2 u_resolution;
uniform float u_barWidth;
uniform float u_gap;
uniform float u_maxHeight;
uniform float u_centerY;
out float v_height;
out float v_barIndex;
out float v_verticalPos;
void main() {
int barIndex = gl_InstanceID;
int barIndex = gl_InstanceID / 2; // Each bar has 2 instances (top + bottom)
int isBottom = gl_InstanceID % 2;
// Sample frequency from texture
float freq = texelFetch(u_frequencies, ivec2(barIndex, 0), 0).r;
// Bar dimensions
float totalWidth = u_barWidth + u_gap;
float totalBarsWidth = float(${NUM_BARS}) * totalWidth - u_gap;
float offsetX = (u_resolution.x - totalBarsWidth) / 2.0;
float x = offsetX + float(barIndex) * totalWidth + a_position.x * u_barWidth;
float y = a_position.y * freq * u_maxHeight;
// Normalize to clip space
// Mirror: top bars go up from center, bottom bars go down
float barHeight = freq * u_maxHeight;
float y;
if (isBottom == 0) {
// Top half: y goes from centerY to centerY + height
y = u_centerY + a_position.y * barHeight;
} else {
// Bottom half: y goes from centerY to centerY - height (mirrored)
y = u_centerY - a_position.y * barHeight;
}
vec2 clipSpace = (vec2(x, y) / u_resolution) * 2.0 - 1.0;
gl_Position = vec4(clipSpace * vec2(1, 1), 0, 1);
v_height = freq;
v_barIndex = float(barIndex) / float(${NUM_BARS});
v_verticalPos = a_position.y;
}
`;
// Fragment shader - gradient coloring
// Fragment shader with enhanced visuals
const fragmentSource = `#version 300 es
precision highp float;
in float v_height;
in float v_barIndex;
in float v_verticalPos;
out vec4 fragColor;
uniform vec3 u_colorLow;
uniform vec3 u_colorMid;
uniform vec3 u_colorHigh;
void main() {
// Vertical gradient based on height
vec3 color = mix(u_colorLow, u_colorHigh, v_height);
// Three-color gradient based on vertical position
vec3 color;
if (v_verticalPos < 0.5) {
color = mix(u_colorLow, u_colorMid, v_verticalPos * 2.0);
} else {
color = mix(u_colorMid, u_colorHigh, (v_verticalPos - 0.5) * 2.0);
}
// Add subtle glow for peaks
float glow = smoothstep(0.6, 1.0, v_height) * 0.4;
// Intensity boost based on frequency height
color *= 0.7 + 0.5 * v_height;
// Glow effect for peaks
float glow = smoothstep(0.5, 1.0, v_height) * 0.3;
color += glow;
// Fade edges slightly for softer look
float alpha = 0.85 + 0.15 * v_height;
// Slight transparency fade at edges
float alpha = 0.9 - 0.1 * v_verticalPos;
fragColor = vec4(color, alpha);
}
`;
function createShader(gl: WebGL2RenderingContext, type: number, source: string): WebGLShader | null {
const shader = gl.createShader(type);
function createShader(glCtx: WebGL2RenderingContext, type: number, source: string): WebGLShader | null {
const shader = glCtx.createShader(type);
if (!shader) return null;
gl.shaderSource(shader, source);
gl.compileShader(shader);
glCtx.shaderSource(shader, source);
glCtx.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error('Shader compile error:', gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
if (!glCtx.getShaderParameter(shader, glCtx.COMPILE_STATUS)) {
console.error('Shader compile error:', glCtx.getShaderInfoLog(shader));
glCtx.deleteShader(shader);
return null;
}
return shader;
}
function createProgram(gl: WebGL2RenderingContext, vs: WebGLShader, fs: WebGLShader): WebGLProgram | null {
const prog = gl.createProgram();
function createProgram(glCtx: WebGL2RenderingContext, vs: WebGLShader, fs: WebGLShader): WebGLProgram | null {
const prog = glCtx.createProgram();
if (!prog) return null;
gl.attachShader(prog, vs);
gl.attachShader(prog, fs);
gl.linkProgram(prog);
glCtx.attachShader(prog, vs);
glCtx.attachShader(prog, fs);
glCtx.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
console.error('Program link error:', gl.getProgramInfoLog(prog));
gl.deleteProgram(prog);
if (!glCtx.getProgramParameter(prog, glCtx.LINK_STATUS)) {
console.error('Program link error:', glCtx.getProgramInfoLog(prog));
glCtx.deleteProgram(prog);
return null;
}
@@ -121,7 +145,7 @@
}
async function initWebGL() {
if (!canvasRef) return;
if (!canvasRef || isInitialized) return;
gl = canvasRef.getContext('webgl2', { alpha: true, premultipliedAlpha: false });
if (!gl) {
@@ -129,7 +153,6 @@
return;
}
// Create shaders
const vs = createShader(gl, gl.VERTEX_SHADER, vertexSource);
const fs = createShader(gl, gl.FRAGMENT_SHADER, fragmentSource);
if (!vs || !fs) return;
@@ -137,17 +160,14 @@
program = createProgram(gl, vs, fs);
if (!program) return;
// Create VAO with quad vertices (2 triangles)
// Create VAO with quad vertices
vao = gl.createVertexArray();
gl.bindVertexArray(vao);
// Quad: bottom-left origin, extends up and right
const positions = new Float32Array([
0, 0, // Bottom-left
1, 0, // Bottom-right
0, 1, // Top-left
0, 1, // Top-left
1, 0, // Bottom-right
1, 1, // Top-right
0, 0, 1, 0, 0, 1,
0, 1, 1, 0, 1, 1,
]);
const posBuffer = gl.createBuffer();
@@ -158,7 +178,7 @@
gl.enableVertexAttribArray(posLoc);
gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0);
// Create 1D texture for frequency data (R32F format)
// Create frequency texture
frequencyTexture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, frequencyTexture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.R32F, NUM_BARS, 1, 0, gl.RED, gl.FLOAT, frequencyData);
@@ -167,7 +187,9 @@
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
// Enable the visualizer in backend
isInitialized = true;
// Enable backend
try {
await invoke('set_visualizer_enabled', { enabled: true });
console.log('[Visualizer] Backend enabled');
@@ -175,15 +197,17 @@
console.error('[Visualizer] Failed to enable backend:', e);
}
// Listen for frequency data from backend
// Listen for frequency data
unlisten = await listen<number[]>('viz:data', (event) => {
// Convert bytes to Float32Array
const bytes = new Uint8Array(event.payload as unknown as ArrayBuffer);
const floats = new Float32Array(bytes.buffer);
if (floats.length === NUM_BARS) {
frequencyData.set(floats);
updateFrequencyTexture();
const payload = event.payload;
if (Array.isArray(payload)) {
// Binary data came as array of numbers (bytes)
const bytes = new Uint8Array(payload);
const floats = new Float32Array(bytes.buffer);
if (floats.length === NUM_BARS) {
frequencyData.set(floats);
updateFrequencyTexture();
}
}
});
@@ -192,7 +216,6 @@
function updateFrequencyTexture() {
if (!gl || !frequencyTexture) return;
gl.bindTexture(gl.TEXTURE_2D, frequencyTexture);
gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, NUM_BARS, 1, gl.RED, gl.FLOAT, frequencyData);
}
@@ -200,7 +223,6 @@
function render() {
if (!gl || !program || !frequencyTexture || !canvasRef || !vao) return;
// Set canvas size
const rect = canvasRef.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvasRef.width = rect.width * dpr;
@@ -213,19 +235,23 @@
gl.useProgram(program);
gl.bindVertexArray(vao);
// Set uniforms
const barWidth = rect.width / NUM_BARS * 0.7;
const gap = rect.width / NUM_BARS * 0.3;
const maxHeight = rect.height * 0.8;
// Calculate dimensions
const barWidth = (rect.width / NUM_BARS) * 0.65;
const gap = (rect.width / NUM_BARS) * 0.35;
const maxHeight = rect.height * 0.35; // Each half gets 35% of height
const centerY = rect.height * 0.5;
// Set uniforms
gl.uniform2f(gl.getUniformLocation(program, 'u_resolution'), rect.width, rect.height);
gl.uniform1f(gl.getUniformLocation(program, 'u_barWidth'), barWidth);
gl.uniform1f(gl.getUniformLocation(program, 'u_gap'), gap);
gl.uniform1f(gl.getUniformLocation(program, 'u_maxHeight'), maxHeight);
gl.uniform1f(gl.getUniformLocation(program, 'u_centerY'), centerY);
// Colors - cyan to magenta gradient
gl.uniform3f(gl.getUniformLocation(program, 'u_colorLow'), 0.0, 0.8, 0.9);
gl.uniform3f(gl.getUniformLocation(program, 'u_colorHigh'), 0.9, 0.2, 0.8);
// Colors: teal -> purple -> pink
gl.uniform3f(gl.getUniformLocation(program, 'u_colorLow'), 0.0, 0.7, 0.7);
gl.uniform3f(gl.getUniformLocation(program, 'u_colorMid'), 0.5, 0.2, 0.8);
gl.uniform3f(gl.getUniformLocation(program, 'u_colorHigh'), 0.9, 0.3, 0.6);
// Bind frequency texture
gl.activeTexture(gl.TEXTURE0);
@@ -236,8 +262,8 @@
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
// Draw instanced bars
gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, NUM_BARS);
// Draw: 2 instances per bar (top + bottom mirror)
gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, NUM_BARS * 2);
animationFrame = requestAnimationFrame(render);
}
@@ -253,7 +279,6 @@
unlisten = null;
}
// Disable visualizer in backend
try {
await invoke('set_visualizer_enabled', { enabled: false });
console.log('[Visualizer] Backend disabled');
@@ -266,45 +291,101 @@
if (program) gl.deleteProgram(program);
if (vao) gl.deleteVertexArray(vao);
}
isInitialized = false;
}
onMount(() => {
if (enabled) {
initWebGL();
}
return cleanup;
});
// React to enabled prop changes
$effect(() => {
if (enabled && !animationFrame) {
if (enabled && !isInitialized) {
initWebGL();
} else if (!enabled && animationFrame) {
} else if (!enabled && isInitialized) {
cleanup();
}
});
</script>
<canvas
bind:this={canvasRef}
class="visualizer-canvas"
class:visible={enabled}
></canvas>
<div class="visualizer-panel" class:visible={enabled}>
<canvas bind:this={canvasRef} class="visualizer-canvas"></canvas>
<!-- Track info overlay -->
<div class="track-info">
{#if artwork}
<img src={artwork} alt={trackTitle} class="artwork" />
{/if}
<div class="text">
<h1 class="title">{trackTitle}</h1>
<p class="artist">{artist}</p>
</div>
</div>
</div>
<style>
.visualizer-canvas {
.visualizer-panel {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 200px;
pointer-events: none;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 300ms ease;
z-index: 5;
}
.visualizer-canvas.visible {
.visualizer-panel.visible {
opacity: 1;
}
.visualizer-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.track-info {
position: relative;
z-index: 10;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 16px;
}
.artwork {
width: min(200px, 25vw);
height: min(200px, 25vw);
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
object-fit: cover;
}
.text {
display: flex;
flex-direction: column;
gap: 4px;
}
.title {
font-size: clamp(18px, 2.5vw, 28px);
font-weight: 700;
color: white;
margin: 0;
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.5);
}
.artist {
font-size: clamp(14px, 1.8vw, 18px);
color: rgba(255, 255, 255, 0.8);
margin: 0;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
}
</style>