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