feat: per-panel FPS settings for all immersive animations

All animations default to 60fps. Each panel reads its own setting
from userStorage via shared fpsConfig utility. Settings UI shows
a collapsed section with per-panel dropdowns:

- Background Blur, Spectrum, X/Y (Lissajous), Oscilloscope,
  Energy Bands, Transient Pulse, Album Reactive, Spectral Ribbon

Options: Disabled, 15, 30, 60, 120 FPS
i18n keys added to all 4 locales (en, es, de, fr)
This commit is contained in:
vicrodh committed 2026-02-28 07:48:02 -06:00
1 parent 8c179ecf00
commit dec2278624
15 files changed
+164 -52

No files matched your search

@@ -3,6 +3,7 @@
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/core';
import QualityBadge from '$lib/components/QualityBadge.svelte';
import { getPanelFrameInterval } from '$lib/immersive/fpsConfig';
interface Props {
enabled?: boolean;
@@ -45,7 +46,7 @@
let animationFrame: number | null = null;
let lastRenderTime = 0;
const FRAME_INTERVAL = 1000 / 30;
const FRAME_INTERVAL = getPanelFrameInterval('album-reactive');
// Artwork-derived glow color
let glowColor = $state('rgba(100, 100, 255, 0.3)');
@@ -3,6 +3,7 @@
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/core';
import QualityBadge from '$lib/components/QualityBadge.svelte';
import { getPanelFrameInterval } from '$lib/immersive/fpsConfig';
interface Props {
enabled?: boolean;
@@ -43,7 +44,7 @@
const energyData = new Float32Array(NUM_BANDS);
let lastRenderTime = 0;
const FRAME_INTERVAL = 1000 / 30;
const FRAME_INTERVAL = getPanelFrameInterval('energy-bands');
let bandColors = $state([
{ r: 255, g: 60, b: 60 },
@@ -3,6 +3,7 @@
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/core';
import QualityBadge from '$lib/components/QualityBadge.svelte';
import { getPanelFrameInterval } from '$lib/immersive/fpsConfig';
interface Props {
enabled?: boolean;
@@ -54,7 +55,7 @@
let trailIndex = 0;
let lastRenderTime = 0;
const FRAME_INTERVAL = 1000 / 30;
const FRAME_INTERVAL = getPanelFrameInterval('lissajous');
// Primary color extracted from artwork
let plotColor = $state({ r: 0, g: 220, b: 200 });
@@ -3,6 +3,7 @@
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/core';
import QualityBadge from '$lib/components/QualityBadge.svelte';
import { getPanelFrameInterval } from '$lib/immersive/fpsConfig';
interface Props {
enabled?: boolean;
@@ -47,7 +48,7 @@
const SMOOTHING = 0.3;
let lastRenderTime = 0;
const FRAME_INTERVAL = 1000 / 30;
const FRAME_INTERVAL = getPanelFrameInterval('oscilloscope');
// Colors extracted from artwork
let colorLeft = $state({ r: 0, g: 220, b: 160 });
@@ -3,6 +3,7 @@
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/core';
import QualityBadge from '$lib/components/QualityBadge.svelte';
import { getPanelFrameInterval } from '$lib/immersive/fpsConfig';
interface Props {
enabled?: boolean;
@@ -40,8 +41,7 @@
const NUM_BANDS = 190;
const HISTORY_BINS = 4096;
const TARGET_FPS = 60;
const FRAME_INTERVAL_MS = 1000 / TARGET_FPS;
const FRAME_INTERVAL_MS = getPanelFrameInterval('spectral-ribbon');
let canvasRef: HTMLCanvasElement | null = $state(null);
let canvasCtx: CanvasRenderingContext2D | null = null;
@@ -3,6 +3,7 @@
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/core';
import QualityBadge from '$lib/components/QualityBadge.svelte';
import { getPanelFrameInterval } from '$lib/immersive/fpsConfig';
interface Props {
enabled?: boolean;
@@ -40,7 +41,7 @@
let isInitialized = false;
let lastRenderTime = 0;
const FRAME_INTERVAL = 1000 / 30;
const FRAME_INTERVAL = getPanelFrameInterval('transient-pulse');
// Ring particles - expanding circles triggered by transients
interface Ring {
@@ -3,6 +3,7 @@
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/core';
import QualityBadge from '$lib/components/QualityBadge.svelte';
import { getPanelFrameInterval } from '$lib/immersive/fpsConfig';
interface Props {
enabled?: boolean;
@@ -50,9 +51,8 @@
// Smoothing for visual continuity
const SMOOTHING = 0.6;
// Throttle rendering to 30fps max
let lastRenderTime = 0;
const FRAME_INTERVAL = 1000 / 30;
const FRAME_INTERVAL = getPanelFrameInterval('visualizer');
// Colors extracted from artwork (Material You style)
let colorPrimary = $state({ r: 0, g: 220, b: 200 }); // Default cyan
@@ -155,7 +155,7 @@
function render(timestamp: number = 0) {
if (!ctx || !canvasRef) return;
// Throttle to 60fps
// Frame throttle
const delta = timestamp - lastRenderTime;
if (delta < FRAME_INTERVAL) {
animationFrame = requestAnimationFrame(render);
+44 -24
View File
@@ -1040,28 +1040,37 @@
}
}
// Immersive ambient background FPS
const AMBIENT_FPS_KEY = 'qbz-immersive-ambient-fps';
const AMBIENT_FPS_OPTIONS = ['0', '15', '30', '60', '120'] as const;
let ambientFps = $state(getUserItem(AMBIENT_FPS_KEY) || '15');
// Immersive FPS settings (per-panel)
const FPS_KEY_PREFIX = 'qbz-immersive-fps-';
const FPS_OPTIONS = ['0', '15', '30', '60', '120'] as const;
const FPS_PANEL_IDS = [
'ambient', 'visualizer', 'lissajous', 'oscilloscope',
'energy-bands', 'transient-pulse', 'album-reactive', 'spectral-ribbon'
] as const;
function getAmbientFpsOptions(): string[] {
return AMBIENT_FPS_OPTIONS.map(val =>
$t(`settings.appearance.ambientFpsOptions.${val === '0' ? 'disabled' : val}`)
let immersiveFpsCollapsed = $state(true);
let panelFpsValues: Record<string, string> = $state(
Object.fromEntries(FPS_PANEL_IDS.map(id => [id, getUserItem(`${FPS_KEY_PREFIX}${id}`) || '60']))
);
function getFpsOptions(): string[] {
return FPS_OPTIONS.map(val =>
$t(`settings.appearance.fpsOptions.${val === '0' ? 'disabled' : val}`)
);
}
function getAmbientFpsDisplayValue(): string {
const key = ambientFps === '0' ? 'disabled' : ambientFps;
return $t(`settings.appearance.ambientFpsOptions.${key}`);
function getFpsDisplayValue(panelId: string): string {
const val = panelFpsValues[panelId] || '60';
const key = val === '0' ? 'disabled' : val;
return $t(`settings.appearance.fpsOptions.${key}`);
}
function handleAmbientFpsChange(displayValue: string) {
const options = getAmbientFpsOptions();
function handleFpsChange(panelId: string, displayValue: string) {
const options = getFpsOptions();
const index = options.indexOf(displayValue);
if (index >= 0) {
ambientFps = AMBIENT_FPS_OPTIONS[index];
setUserItem(AMBIENT_FPS_KEY, ambientFps);
panelFpsValues[panelId] = FPS_OPTIONS[index];
setUserItem(`${FPS_KEY_PREFIX}${panelId}`, FPS_OPTIONS[index]);
}
}
@@ -4124,16 +4133,27 @@
onchange={handleImmersiveViewChange}
/>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">{$t('settings.appearance.ambientFps')}</span>
<span class="setting-desc">{$t('settings.appearance.ambientFpsDesc')}</span>
</div>
<Dropdown
value={getAmbientFpsDisplayValue()}
options={getAmbientFpsOptions()}
onchange={handleAmbientFpsChange}
/>
<div class="collapsible-section">
<button
class="section-title-btn"
onclick={() => immersiveFpsCollapsed = !immersiveFpsCollapsed}
>
<h4 class="subsection-title">{$t('settings.appearance.immersiveFps.title')}</h4>
<span class="collapse-icon" class:collapsed={immersiveFpsCollapsed}>▼</span>
</button>
{#if !immersiveFpsCollapsed}
<p class="setting-desc" style="margin-bottom: 12px;">{$t('settings.appearance.immersiveFps.desc')}</p>
{#each FPS_PANEL_IDS as panelId}
<div class="setting-row">
<span class="setting-label">{$t(`settings.appearance.immersiveFps.panels.${panelId}`)}</span>
<Dropdown
value={getFpsDisplayValue(panelId)}
options={getFpsOptions()}
onchange={(val) => handleFpsChange(panelId, val)}
/>
</div>
{/each}
{/if}
</div>
<div class="setting-row">
<span class="setting-label">{$t('settings.appearance.miniplayerDefaultView')}</span>
+15 -3
View File
@@ -760,9 +760,21 @@
"split-suggestions": "Geteilt: Vorschläge",
"split-queue": "Geteilt: Warteschlange"
},
"ambientFps": "Immersive Hintergrundanimation",
"ambientFpsDesc": "Bildrate für den Hintergrundeffekt im Immersiv-Modus. Niedrigere Werte sparen CPU. Erfordert erneutes Aufrufen des Immersiv-Modus.",
"ambientFpsOptions": {
"immersiveFps": {
"title": "Bildrate der Immersiv-Animationen",
"desc": "Bildrate pro Animation konfigurieren. Niedrigere Werte sparen CPU. Erfordert erneutes Aufrufen des Immersiv-Modus.",
"panels": {
"ambient": "Hintergrund-Unschärfe",
"visualizer": "Spektrum-Visualizer",
"lissajous": "X/Y (Lissajous)",
"oscilloscope": "Oszilloskop",
"energy-bands": "Energiebänder",
"transient-pulse": "Transientenpuls",
"album-reactive": "Album-Reaktiv",
"spectral-ribbon": "Spektralband"
}
},
"fpsOptions": {
"disabled": "Deaktiviert",
"15": "15 FPS (Niedrig)",
"30": "30 FPS (Mittel)",
+15 -3
View File
@@ -788,9 +788,21 @@
"split-suggestions": "Split: Suggestions",
"split-queue": "Split: Queue"
},
"ambientFps": "Immersive background animation",
"ambientFpsDesc": "Frame rate for the ambient background effect in immersive mode. Lower values save CPU. Requires re-entering immersive mode.",
"ambientFpsOptions": {
"immersiveFps": {
"title": "Immersive Animation Frame Rate",
"desc": "Configure frame rate per animation. Lower values save CPU. Requires re-entering immersive mode to apply.",
"panels": {
"ambient": "Background Blur",
"visualizer": "Spectrum Visualizer",
"lissajous": "X/Y (Lissajous)",
"oscilloscope": "Oscilloscope",
"energy-bands": "Energy Bands",
"transient-pulse": "Transient Pulse",
"album-reactive": "Album Reactive",
"spectral-ribbon": "Spectral Ribbon"
}
},
"fpsOptions": {
"disabled": "Disabled",
"15": "15 FPS (Low)",
"30": "30 FPS (Medium)",
+15 -3
View File
@@ -788,9 +788,21 @@
"split-suggestions": "Dividido: Sugerencias",
"split-queue": "Dividido: Cola"
},
"ambientFps": "Animación de fondo inmersivo",
"ambientFpsDesc": "Tasa de cuadros del efecto de fondo en el modo inmersivo. Valores bajos ahorran CPU. Requiere volver a entrar al modo inmersivo.",
"ambientFpsOptions": {
"immersiveFps": {
"title": "Tasa de Cuadros de Animaciones Inmersivas",
"desc": "Configura la tasa de cuadros por animación. Valores bajos ahorran CPU. Requiere volver a entrar al modo inmersivo.",
"panels": {
"ambient": "Fondo Difuminado",
"visualizer": "Espectro",
"lissajous": "X/Y (Lissajous)",
"oscilloscope": "Osciloscopio",
"energy-bands": "Bandas de Energía",
"transient-pulse": "Pulso Transitorio",
"album-reactive": "Álbum Reactivo",
"spectral-ribbon": "Cinta Espectral"
}
},
"fpsOptions": {
"disabled": "Desactivado",
"15": "15 FPS (Bajo)",
"30": "30 FPS (Medio)",
+15 -3
View File
@@ -760,9 +760,21 @@
"split-suggestions": "Divisé : Suggestions",
"split-queue": "Divisé : File d'attente"
},
"ambientFps": "Animation de fond immersif",
"ambientFpsDesc": "Fréquence d'images de l'effet de fond en mode immersif. Des valeurs basses économisent le CPU. Nécessite de ré-entrer en mode immersif.",
"ambientFpsOptions": {
"immersiveFps": {
"title": "Fréquence d'Images des Animations Immersives",
"desc": "Configurez la fréquence d'images par animation. Des valeurs basses économisent le CPU. Nécessite de ré-entrer en mode immersif.",
"panels": {
"ambient": "Flou d'Arrière-plan",
"visualizer": "Visualiseur de Spectre",
"lissajous": "X/Y (Lissajous)",
"oscilloscope": "Oscilloscope",
"energy-bands": "Bandes d'Énergie",
"transient-pulse": "Impulsion Transitoire",
"album-reactive": "Album Réactif",
"spectral-ribbon": "Ruban Spectral"
}
},
"fpsOptions": {
"disabled": "Désactivé",
"15": "15 FPS (Bas)",
"30": "30 FPS (Moyen)",
@@ -34,7 +34,7 @@
handleContextRestored,
getConfig,
} from './ImmersiveRenderer';
import { getUserItem } from '$lib/utils/userStorage';
import { getPanelFps, getPanelFrameInterval } from './fpsConfig';
// Props
interface Props {
@@ -81,10 +81,9 @@
let lastFrameTime = 0;
let isVisible = true;
// Frame throttling — read user preference (0 = disabled, default 15)
const AMBIENT_FPS_KEY = 'qbz-immersive-ambient-fps';
const ambientFps = parseInt(getUserItem(AMBIENT_FPS_KEY) || '15', 10);
const FRAME_INTERVAL = ambientFps > 0 ? 1000 / ambientFps : 0;
// Frame throttling — read user preference (0 = disabled, default 60)
const ambientFps = getPanelFps('ambient');
const FRAME_INTERVAL = getPanelFrameInterval('ambient');
/**
* Initialize WebGL resources.
+40
View File
@@ -0,0 +1,40 @@
/**
* Immersive FPS Configuration
*
* Reads per-panel frame rate settings from userStorage.
* Default is 60fps for all panels.
*/
import { getUserItem } from '$lib/utils/userStorage';
const FPS_KEY_PREFIX = 'qbz-immersive-fps-';
const DEFAULT_FPS = 60;
export type ImmersivePanelId =
| 'ambient'
| 'visualizer'
| 'lissajous'
| 'oscilloscope'
| 'energy-bands'
| 'transient-pulse'
| 'album-reactive'
| 'spectral-ribbon';
/**
* Get the configured FPS for a panel.
* Returns 0 for disabled, or the FPS value.
*/
export function getPanelFps(panelId: ImmersivePanelId): number {
const stored = getUserItem(`${FPS_KEY_PREFIX}${panelId}`);
if (stored === null) return DEFAULT_FPS;
const parsed = parseInt(stored, 10);
return isNaN(parsed) ? DEFAULT_FPS : parsed;
}
/**
* Get the frame interval in ms for a panel (0 if disabled).
*/
export function getPanelFrameInterval(panelId: ImmersivePanelId): number {
const fps = getPanelFps(panelId);
return fps > 0 ? 1000 / fps : 0;
}
+1 -1
View File
@@ -56,7 +56,7 @@ export interface ImmersiveConfig {
/** Default configuration */
export const DEFAULT_IMMERSIVE_CONFIG: ImmersiveConfig = {
targetFps: 15,
targetFps: 60,
ambientIntensity: 1.0, // Full intensity for maximum ambient motion
pauseWhenHidden: true,
debugOverlay: false,