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">
|
||||
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>
|
||||
Reference in new issue
Block a user