From d9172efae90da39a4405900f359fd213f67aa828 Mon Sep 17 00:00:00 2001
From: Jeff <42182408+jeffvli@users.noreply.github.com>
Date: Wed, 24 Dec 2025 18:12:13 -0800
Subject: [PATCH] Add visualizer configuration (#1443)
* add visualizer configuration
* add visualizer presets
* add butterchurn visualizer
* wrap visualizers in error boundary
---
package.json | 2 +
pnpm-lock.yaml | 53 +-
src/i18n/locales/en.json | 143 ++
.../components/sidebar-play-queue.tsx | 30 +-
.../components/full-screen-player-queue.tsx | 19 +-
.../player/components/visualizer.module.css | 11 -
.../features/player/components/visualizer.tsx | 40 -
.../utils/open-visualizer-settings-modal.ts | 25 +
.../visualizer-settings-form.module.css | 11 +
.../visualizer-settings-form.tsx | 2040 +++++++++++++++++
.../visualizer-settings-modal.tsx | 5 +
.../audiomotionanalyzer/visualizer.module.css | 28 +
.../audiomotionanalyzer/visualizer.tsx | 243 ++
.../components/butternchurn/butterchurn.d.ts | 7 +
.../butternchurn/visualizer.module.css | 43 +
.../components/butternchurn/visualizer.tsx | 316 +++
src/renderer/router/app-router.tsx | 2 +
src/renderer/store/settings.store.ts | 182 ++
.../components/angle-slider/angle-slider.tsx | 13 +
.../components/fieldset/fieldset.module.css | 5 +
src/shared/components/fieldset/fieldset.tsx | 21 +
src/shared/components/slider/slider.tsx | 38 +-
22 files changed, 3197 insertions(+), 80 deletions(-)
delete mode 100644 src/renderer/features/player/components/visualizer.module.css
delete mode 100644 src/renderer/features/player/components/visualizer.tsx
create mode 100644 src/renderer/features/player/utils/open-visualizer-settings-modal.ts
create mode 100644 src/renderer/features/visualizer/components/audiomotionanalyzer/visualizer-settings-form.module.css
create mode 100644 src/renderer/features/visualizer/components/audiomotionanalyzer/visualizer-settings-form.tsx
create mode 100644 src/renderer/features/visualizer/components/audiomotionanalyzer/visualizer-settings-modal.tsx
create mode 100644 src/renderer/features/visualizer/components/audiomotionanalyzer/visualizer.module.css
create mode 100644 src/renderer/features/visualizer/components/audiomotionanalyzer/visualizer.tsx
create mode 100644 src/renderer/features/visualizer/components/butternchurn/butterchurn.d.ts
create mode 100644 src/renderer/features/visualizer/components/butternchurn/visualizer.module.css
create mode 100644 src/renderer/features/visualizer/components/butternchurn/visualizer.tsx
create mode 100644 src/shared/components/angle-slider/angle-slider.tsx
create mode 100644 src/shared/components/fieldset/fieldset.module.css
create mode 100644 src/shared/components/fieldset/fieldset.tsx
diff --git a/package.json b/package.json
index 9b6e6a85d..ada6db758 100644
--- a/package.json
+++ b/package.json
@@ -82,6 +82,8 @@
"@xhayper/discord-rpc": "^1.3.0",
"audiomotion-analyzer": "^4.5.1",
"axios": "^1.13.2",
+ "butterchurn": "^2.6.7",
+ "butterchurn-presets": "^2.4.7",
"cheerio": "^1.1.2",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 0548c0efd..c9025b34f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -71,6 +71,12 @@ importers:
axios:
specifier: ^1.13.2
version: 1.13.2
+ butterchurn:
+ specifier: ^2.6.7
+ version: 2.6.7
+ butterchurn-presets:
+ specifier: ^2.4.7
+ version: 2.4.7
cheerio:
specifier: ^1.1.2
version: 1.1.2
@@ -2263,6 +2269,9 @@ packages:
peerDependencies:
'@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0
+ babel-runtime@6.26.0:
+ resolution: {integrity: sha512-ITKNuq2wKlW1fJg9sSW52eepoYgZBggvOAHC0u/CYu/qxQ9EVzThCgR69BnSXLHjy2f7SY5zaQ4yt7H9ZVxY2g==}
+
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
@@ -2359,6 +2368,12 @@ packages:
builder-util@26.0.11:
resolution: {integrity: sha512-xNjXfsldUEe153h1DraD0XvDOpqGR0L5eKFkdReB7eFW5HqysDZFfly4rckda6y9dF39N3pkPlOblcfHKGw+uA==}
+ butterchurn-presets@2.4.7:
+ resolution: {integrity: sha512-4MdM8ripz/VfH1BCldrIKdAc/1ryJFBDvqlyow6Ivo1frwj0H3duzvSMFC7/wIjAjxb1QpwVHVqGqS9uAFKhpg==}
+
+ butterchurn@2.6.7:
+ resolution: {integrity: sha512-BJiRA8L0L2+84uoG2SSfkp0kclBuN+vQKf217pK7pMlwEO2ZEg3MtO2/o+l8Qpr8Nbejg8tmL1ZHD1jmhiaaqg==}
+
cac@6.7.14:
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
engines: {node: '>=8'}
@@ -2555,6 +2570,10 @@ packages:
core-js-compat@3.47.0:
resolution: {integrity: sha512-IGfuznZ/n7Kp9+nypamBhvwdwLsW6KC8IOaURw2doAK5e98AG3acVLdh0woOnEqCfUtS+Vu882JE4k/DAm3ItQ==}
+ core-js@2.6.12:
+ resolution: {integrity: sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==}
+ deprecated: core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.
+
core-util-is@1.0.2:
resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==}
@@ -2779,6 +2798,9 @@ packages:
eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
+ ecma-proposal-math-extensions@0.0.2:
+ resolution: {integrity: sha512-80BnDp2Fn7RxXlEr5HHZblniY4aQ97MOAicdWWpSo0vkQiISSE9wLR4SqxKsu4gCtXFBIPPzy8JMhay4NWRg/Q==}
+
ejs@3.1.10:
resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==}
engines: {node: '>=0.10.0'}
@@ -4688,6 +4710,9 @@ packages:
regenerate@1.4.2:
resolution: {integrity: sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==}
+ regenerator-runtime@0.11.1:
+ resolution: {integrity: sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==}
+
regexp.prototype.flags@1.5.4:
resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
engines: {node: '>= 0.4'}
@@ -7998,6 +8023,11 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ babel-runtime@6.26.0:
+ dependencies:
+ core-js: 2.6.12
+ regenerator-runtime: 0.11.1
+
balanced-match@1.0.2: {}
balanced-match@2.0.0: {}
@@ -8134,6 +8164,17 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ butterchurn-presets@2.4.7:
+ dependencies:
+ babel-runtime: 6.26.0
+ ecma-proposal-math-extensions: 0.0.2
+ lodash: 4.17.21
+
+ butterchurn@2.6.7:
+ dependencies:
+ '@babel/runtime': 7.28.4
+ ecma-proposal-math-extensions: 0.0.2
+
cac@6.7.14: {}
cacache@16.1.3:
@@ -8361,6 +8402,8 @@ snapshots:
dependencies:
browserslist: 4.28.0
+ core-js@2.6.12: {}
+
core-util-is@1.0.2:
optional: true
@@ -8604,6 +8647,8 @@ snapshots:
eastasianwidth@0.2.0: {}
+ ecma-proposal-math-extensions@0.0.2: {}
+
ejs@3.1.10:
dependencies:
jake: 10.9.2
@@ -9526,7 +9571,7 @@ snapshots:
i18next@24.2.3(typescript@5.8.3):
dependencies:
- '@babel/runtime': 7.27.1
+ '@babel/runtime': 7.28.4
optionalDependencies:
typescript: 5.8.3
@@ -10598,7 +10643,7 @@ snapshots:
react-textarea-autosize@8.5.9(@types/react@19.2.5)(react@19.1.0):
dependencies:
- '@babel/runtime': 7.27.1
+ '@babel/runtime': 7.28.4
react: 19.1.0
use-composed-ref: 1.4.0(@types/react@19.2.5)(react@19.1.0)
use-latest: 1.3.0(@types/react@19.2.5)(react@19.1.0)
@@ -10607,7 +10652,7 @@ snapshots:
react-transition-group@4.4.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
- '@babel/runtime': 7.27.1
+ '@babel/runtime': 7.28.4
dom-helpers: 5.2.1
loose-envify: 1.4.0
prop-types: 15.8.1
@@ -10672,6 +10717,8 @@ snapshots:
regenerate@1.4.2: {}
+ regenerator-runtime@0.11.1: {}
+
regexp.prototype.flags@1.5.4:
dependencies:
call-bind: 1.0.8
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index 8d4ac0f52..ffb0a42b8 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -1095,5 +1095,148 @@
"error_oneFileOnly": "Please only select 1 file",
"error_readingFile": "there has been an issue reading the file: {{errorMessage}}",
"mainText": "drop a file here"
+ },
+ "visualizer": {
+ "visualizerType": "Visualizer Type",
+ "cyclePresets": "Cycle Presets",
+ "cycleTime": "Cycle Time (seconds)",
+ "includeAllPresets": "Include All Presets",
+ "selectedPresets": "Selected Presets",
+ "randomizeNextPreset": "Randomize Next Preset",
+ "blendTime": "Blend Time",
+ "presets": "Presets",
+ "selectPreset": "Select Preset",
+ "applyPreset": "Apply Preset",
+ "saveAsPreset": "Save as Preset",
+ "updatePreset": "Update Preset",
+ "copyConfiguration": "Copy Configuration",
+ "pasteConfiguration": "Paste Configuration",
+ "pasteConfigurationPlaceholder": "Paste JSON configuration here...",
+ "pasteFromClipboard": "Paste from Clipboard",
+ "applyConfiguration": "Apply Configuration",
+ "configCopied": "Configuration copied to clipboard",
+ "configCopyFailed": "Failed to copy configuration",
+ "configPasted": "Configuration applied successfully",
+ "configPasteFailed": "Failed to apply configuration. Please check the format.",
+ "configPasteReadFailed": "Failed to read from clipboard",
+ "presetName": "Preset Name",
+ "presetNamePlaceholder": "Enter preset name",
+ "general": "General",
+ "mode": "Mode",
+ "mode1To8": "Mode 1 - 8",
+ "mode10": "Mode 10",
+ "barSpace": "Bar Space",
+ "lineWidth": "Line Width",
+ "fillAlpha": "Fill Alpha",
+ "channelLayout": "Channel Layout",
+ "maxFPS": "Max FPS",
+ "customGradients": "Custom Gradients",
+ "addCustomGradient": "Add Custom Gradient",
+ "gradientName": "Gradient Name",
+ "gradientNamePlaceholder": "Gradient Name",
+ "vertical": "Vertical",
+ "horizontal": "Horizontal",
+ "colorStops": "Color Stops",
+ "addColor": "Add Color",
+ "position": "Position",
+ "level": "Level",
+ "remove": "Remove",
+ "custom": "Custom",
+ "builtIn": "Built-in",
+ "colors": "Colors",
+ "colorMode": "Color Mode",
+ "gradient": "Gradient",
+ "gradientLeft": "Gradient Left",
+ "gradientRight": "Gradient Right",
+ "fft": "FFT",
+ "fftSize": "FFT Size",
+ "smoothing": "Smoothing",
+ "frequencyRangeAndScaling": "Frequency range and scaling",
+ "minimumFrequency": "Minimum Frequency",
+ "maximumFrequency": "Maximum Frequency",
+ "frequencyScale": "Frequency Scale",
+ "sensitivity": "Sensitivity",
+ "weightingFilter": "Weighting Filter",
+ "minimumDecibels": "Minimum Decibels",
+ "maximumDecibels": "Maximum Decibels",
+ "linearAmplitude": "Linear Amplitude",
+ "linearBoost": "Linear Boost",
+ "peakBehavior": "Peak Behavior",
+ "showPeaks": "Show Peaks",
+ "fadePeaks": "Fade Peaks",
+ "peakLine": "Peak Line",
+ "gravity": "Gravity",
+ "peakFadeTime": "Peak Fade Time (ms)",
+ "peakHoldTime": "Peak Hold Time (ms)",
+ "radialSpectrum": "Radial Spectrum",
+ "radial": "Radial",
+ "radialInvert": "Radial Invert",
+ "radius": "Radius",
+ "reflexMirror": "Reflex Mirror",
+ "reflexFit": "Reflex Fit",
+ "reflexRatio": "Reflex Ratio",
+ "reflexAlpha": "Reflex Alpha",
+ "reflexBrightness": "Reflex Brightness",
+ "mirror": "Mirror",
+ "miscellaneousSettings": "Miscellaneous Settings",
+ "alphaBars": "Alpha Bars",
+ "ansiBands": "ANSI Bands",
+ "ledBars": "LED Bars",
+ "trueLeds": "True LEDs",
+ "lumiBars": "Lumi Bars",
+ "outlineBars": "Outline Bars",
+ "roundBars": "Round Bars",
+ "lowResolution": "Low Resolution",
+ "splitGradient": "Split Gradient",
+ "showFPS": "Show FPS",
+ "showScaleX": "Show Scale X",
+ "noteLabels": "Note Labels",
+ "showScaleY": "Show Scale Y",
+ "options": {
+ "mode": {
+ "bars": "[0] Bars",
+ "circle": "[1] Circle",
+ "wave": "[2] Wave",
+ "rainbow": "[3] Rainbow",
+ "rings": "[4] Rings",
+ "mirror": "[5] Mirror",
+ "line": "[6] Line",
+ "particles": "[7] Particles",
+ "fullOctave": "[8] Full octave / 10 bands",
+ "outlineBars": "[10] Outline bars"
+ },
+ "colorMode": {
+ "gradient": "Gradient",
+ "barIndex": "Bar-Index",
+ "barLevel": "Bar-Level"
+ },
+ "gradient": {
+ "classic": "Classic",
+ "prism": "Prism",
+ "rainbow": "Rainbow",
+ "steelblue": "Steelblue",
+ "orangered": "Orangered"
+ },
+ "channelLayout": {
+ "single": "Single",
+ "dualCombined": "Dual-Combined",
+ "dualHorizontal": "Dual-Horizontal",
+ "dualVertical": "Dual-Vertical"
+ },
+ "frequencyScale": {
+ "bark": "Bark",
+ "linear": "Linear",
+ "log": "Log",
+ "mel": "Mel"
+ },
+ "weightingFilter": {
+ "none": "None",
+ "a": "A",
+ "b": "B",
+ "c": "C",
+ "d": "D",
+ "z": "Z"
+ }
+ }
}
}
diff --git a/src/renderer/features/now-playing/components/sidebar-play-queue.tsx b/src/renderer/features/now-playing/components/sidebar-play-queue.tsx
index 3e138c3d7..14d76eef2 100644
--- a/src/renderer/features/now-playing/components/sidebar-play-queue.tsx
+++ b/src/renderer/features/now-playing/components/sidebar-play-queue.tsx
@@ -8,14 +8,25 @@ import { lyricsQueries } from '/@/renderer/features/lyrics/api/lyrics-api';
import { Lyrics } from '/@/renderer/features/lyrics/lyrics';
import { PlayQueue } from '/@/renderer/features/now-playing/components/play-queue';
import { PlayQueueListControls } from '/@/renderer/features/now-playing/components/play-queue-list-controls';
-import { useGeneralSettings, usePlaybackSettings, usePlayerSong } from '/@/renderer/store';
+import {
+ useGeneralSettings,
+ usePlaybackSettings,
+ usePlayerSong,
+ useSettingsStore,
+} from '/@/renderer/store';
import { Divider } from '/@/shared/components/divider/divider';
import { Flex } from '/@/shared/components/flex/flex';
import { Stack } from '/@/shared/components/stack/stack';
import { ItemListKey, PlayerType } from '/@/shared/types/types';
-const Visualizer = lazy(() =>
- import('/@/renderer/features/player/components/visualizer').then((module) => ({
+const AudioMotionAnalyzerVisualizer = lazy(() =>
+ import('../../visualizer/components/audiomotionanalyzer/visualizer').then((module) => ({
+ default: module.Visualizer,
+ })),
+);
+
+const ButterchurnVisualizer = lazy(() =>
+ import('../../visualizer/components/butternchurn/visualizer').then((module) => ({
default: module.Visualizer,
})),
);
@@ -48,6 +59,7 @@ export const SidebarPlayQueue = () => {
const BottomPanel = () => {
const { showLyricsInSidebar, showVisualizerInSidebar } = useGeneralSettings();
const { type, webAudio } = usePlaybackSettings();
+ const visualizerType = useSettingsStore((store) => store.visualizer.type);
const currentSong = usePlayerSong();
const { data: lyricsData } = useQuery(
@@ -102,7 +114,11 @@ const BottomPanel = () => {
}}
>
>}>
-
+ {visualizerType === 'butterchurn' ? (
+
+ ) : (
+
+ )}
)}
@@ -111,7 +127,11 @@ const BottomPanel = () => {
showVisualizer && (
>}>
-
+ {visualizerType === 'butterchurn' ? (
+
+ ) : (
+
+ )}
)
diff --git a/src/renderer/features/player/components/full-screen-player-queue.tsx b/src/renderer/features/player/components/full-screen-player-queue.tsx
index 82f84f7d6..e65e26b33 100644
--- a/src/renderer/features/player/components/full-screen-player-queue.tsx
+++ b/src/renderer/features/player/components/full-screen-player-queue.tsx
@@ -8,7 +8,7 @@ import styles from './full-screen-player-queue.module.css';
import { Lyrics } from '/@/renderer/features/lyrics/lyrics';
import { PlayQueue } from '/@/renderer/features/now-playing/components/play-queue';
import { FullScreenSimilarSongs } from '/@/renderer/features/player/components/full-screen-similar-songs';
-import { usePlaybackSettings } from '/@/renderer/store';
+import { usePlaybackSettings, useSettingsStore } from '/@/renderer/store';
import {
useFullScreenPlayerStore,
useFullScreenPlayerStoreActions,
@@ -17,8 +17,14 @@ import { Button } from '/@/shared/components/button/button';
import { Group } from '/@/shared/components/group/group';
import { ItemListKey, PlayerType } from '/@/shared/types/types';
-const Visualizer = lazy(() =>
- import('/@/renderer/features/player/components/visualizer').then((module) => ({
+const AudioMotionAnalyzerVisualizer = lazy(() =>
+ import('../../visualizer/components/audiomotionanalyzer/visualizer').then((module) => ({
+ default: module.Visualizer,
+ })),
+);
+
+const ButterchurnVisualizer = lazy(() =>
+ import('../../visualizer/components/butternchurn/visualizer').then((module) => ({
default: module.Visualizer,
})),
);
@@ -28,6 +34,7 @@ export const FullScreenPlayerQueue = () => {
const { activeTab, opacity } = useFullScreenPlayerStore();
const { setStore } = useFullScreenPlayerStoreActions();
const { type, webAudio } = usePlaybackSettings();
+ const visualizerType = useSettingsStore((store) => store.visualizer.type);
const headerItems = useMemo(() => {
const items = [
@@ -109,7 +116,11 @@ export const FullScreenPlayerQueue = () => {
) : activeTab === 'visualizer' && type === PlayerType.WEB && webAudio ? (
>}>
-
+ {visualizerType === 'butterchurn' ? (
+
+ ) : (
+
+ )}
) : null}
diff --git a/src/renderer/features/player/components/visualizer.module.css b/src/renderer/features/player/components/visualizer.module.css
deleted file mode 100644
index d9fad01d8..000000000
--- a/src/renderer/features/player/components/visualizer.module.css
+++ /dev/null
@@ -1,11 +0,0 @@
-.container {
- z-index: 50;
- width: 100%;
- height: 100%;
- margin: auto;
-
- canvas {
- width: 100%;
- margin: auto;
- }
-}
diff --git a/src/renderer/features/player/components/visualizer.tsx b/src/renderer/features/player/components/visualizer.tsx
deleted file mode 100644
index 94c4cfd2b..000000000
--- a/src/renderer/features/player/components/visualizer.tsx
+++ /dev/null
@@ -1,40 +0,0 @@
-import AudioMotionAnalyzer from 'audiomotion-analyzer';
-import { createRef, useEffect, useState } from 'react';
-
-import styles from './visualizer.module.css';
-
-import { useWebAudio } from '/@/renderer/features/player/hooks/use-webaudio';
-import { useSettingsStore } from '/@/renderer/store';
-
-export const Visualizer = () => {
- const { webAudio } = useWebAudio();
- const canvasRef = createRef();
- const accent = useSettingsStore((store) => store.general.accent);
- const [motion, setMotion] = useState();
-
- useEffect(() => {
- const { context, gains } = webAudio || {};
- if (gains && context && canvasRef.current && !motion) {
- const audioMotion = new AudioMotionAnalyzer(canvasRef.current, {
- ansiBands: true,
- audioCtx: context,
- connectSpeakers: false,
- gradient: 'prism',
- ledBars: true,
- mode: 8,
- overlay: true,
- showBgColor: false,
- showPeaks: false,
- showScaleX: false,
- showScaleY: false,
- smoothing: 0.8,
- });
- setMotion(audioMotion);
- for (const gain of gains) audioMotion.connectInput(gain);
- }
-
- return () => {};
- }, [accent, canvasRef, motion, webAudio]);
-
- return ;
-};
diff --git a/src/renderer/features/player/utils/open-visualizer-settings-modal.ts b/src/renderer/features/player/utils/open-visualizer-settings-modal.ts
new file mode 100644
index 000000000..cbdf012e3
--- /dev/null
+++ b/src/renderer/features/player/utils/open-visualizer-settings-modal.ts
@@ -0,0 +1,25 @@
+import { openContextModal } from '@mantine/modals';
+
+export const openVisualizerSettingsModal = () => {
+ openContextModal({
+ innerProps: {},
+ modalKey: 'visualizerSettings',
+ overlayProps: {
+ blur: 0,
+ opacity: 1,
+ },
+ size: 'xl',
+ styles: {
+ content: {
+ height: '90%',
+ maxWidth: '1400px',
+ minHeight: '600px',
+ width: '100%',
+ },
+ },
+ title: 'Visualizer Settings',
+ transitionProps: {
+ transition: 'pop',
+ },
+ });
+};
diff --git a/src/renderer/features/visualizer/components/audiomotionanalyzer/visualizer-settings-form.module.css b/src/renderer/features/visualizer/components/audiomotionanalyzer/visualizer-settings-form.module.css
new file mode 100644
index 000000000..4182b6a7e
--- /dev/null
+++ b/src/renderer/features/visualizer/components/audiomotionanalyzer/visualizer-settings-form.module.css
@@ -0,0 +1,11 @@
+.container {
+ display: flex;
+ flex-direction: column;
+ gap: var(--theme-spacing-md);
+ width: 100%;
+ margin: 0 auto;
+}
+
+.select-label {
+ text-align: center;
+}
diff --git a/src/renderer/features/visualizer/components/audiomotionanalyzer/visualizer-settings-form.tsx b/src/renderer/features/visualizer/components/audiomotionanalyzer/visualizer-settings-form.tsx
new file mode 100644
index 000000000..701e99a88
--- /dev/null
+++ b/src/renderer/features/visualizer/components/audiomotionanalyzer/visualizer-settings-form.tsx
@@ -0,0 +1,2040 @@
+import { ConstructorOptions } from 'audiomotion-analyzer';
+import butterchurnPresets from 'butterchurn-presets';
+import { useEffect, useMemo, useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import styles from './visualizer-settings-form.module.css';
+
+import { useSettingsStoreActions, useVisualizerSettings } from '/@/renderer/store/settings.store';
+import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
+import { Button } from '/@/shared/components/button/button';
+import { Checkbox } from '/@/shared/components/checkbox/checkbox';
+import { ColorInput } from '/@/shared/components/color-input/color-input';
+import { Divider } from '/@/shared/components/divider/divider';
+import { Fieldset } from '/@/shared/components/fieldset/fieldset';
+import { Group } from '/@/shared/components/group/group';
+import { MultiSelect } from '/@/shared/components/multi-select/multi-select';
+import { NumberInput } from '/@/shared/components/number-input/number-input';
+import { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control';
+import { Select, SelectProps } from '/@/shared/components/select/select';
+import { Slider, SliderProps } from '/@/shared/components/slider/slider';
+import { Stack } from '/@/shared/components/stack/stack';
+import { TextInput } from '/@/shared/components/text-input/text-input';
+import { Text } from '/@/shared/components/text/text';
+import { Textarea } from '/@/shared/components/textarea/textarea';
+import { toast } from '/@/shared/components/toast/toast';
+
+const modeOptions: { label: string; value: ConstructorOptions['mode'] | string }[] = [
+ { label: '[0] Bars', value: '0' },
+ { label: '[1] Circle', value: '1' },
+ { label: '[2] Wave', value: '2' },
+ { label: '[3] Rainbow', value: '3' },
+ { label: '[4] Rings', value: '4' },
+ { label: '[5] Mirror', value: '5' },
+ { label: '[6] Line', value: '6' },
+ { label: '[7] Particles', value: '7' },
+ { label: '[8] Full octave / 10 bands', value: '8' },
+ { label: '[10] Outline bars', value: '10' },
+];
+
+const colorModeOptions: { label: string; value: ConstructorOptions['colorMode'] }[] = [
+ { label: 'Gradient', value: 'gradient' },
+ { label: 'Bar-Index', value: 'bar-index' },
+ { label: 'Bar-Level', value: 'bar-level' },
+];
+
+const gradientOptions: { label: string; value: ConstructorOptions['gradient'] }[] = [
+ { label: 'Classic', value: 'classic' },
+ { label: 'Prism', value: 'prism' },
+ { label: 'Rainbow', value: 'rainbow' },
+ { label: 'Steelblue', value: 'steelblue' },
+ { label: 'Orangered', value: 'orangered' },
+];
+
+const channelLayoutOptions: { label: string; value: ConstructorOptions['channelLayout'] }[] = [
+ { label: 'Single', value: 'single' },
+ { label: 'Dual-Combined', value: 'dual-combined' },
+ { label: 'Dual-Horizontal', value: 'dual-horizontal' },
+ { label: 'Dual-Vertical', value: 'dual-vertical' },
+];
+
+const fftSizeOptions: { label: string; value: ConstructorOptions['fftSize'] | string }[] = [
+ { label: '1024', value: '1024' },
+ { label: '2048', value: '2048' },
+ { label: '4096', value: '4096' },
+ { label: '8192', value: '8192' },
+ { label: '16384', value: '16384' },
+ { label: '32768', value: '32768' },
+];
+
+const frequencyScaleOptions: { label: string; value: ConstructorOptions['frequencyScale'] }[] = [
+ { label: 'Bark', value: 'bark' },
+ { label: 'Linear', value: 'linear' },
+ { label: 'Log', value: 'log' },
+ { label: 'Mel', value: 'mel' },
+];
+
+const weightingFilterOptions = [
+ { label: 'None', value: '' },
+ { label: 'A', value: 'A' },
+ { label: 'B', value: 'B' },
+ { label: 'C', value: 'C' },
+ { label: 'D', value: 'D' },
+ { label: 'Z', value: 'Z' },
+];
+
+const minFreqOptions = [
+ { label: '20', value: '20' },
+ { label: '30', value: '30' },
+ { label: '40', value: '40' },
+ { label: '50', value: '50' },
+];
+
+const maxFreqOptions = [
+ { label: '8000', value: '8000' },
+ { label: '10000', value: '10000' },
+ { label: '15000', value: '15000' },
+ { label: '20000', value: '20000' },
+ { label: '22050', value: '22050' },
+];
+
+const barSpaceOptions = [
+ { label: '0', value: '0' },
+ { label: '0.1', value: '0.1' },
+ { label: '0.25', value: '0.2' },
+ { label: '0.4', value: '0.4' },
+ { label: '0.5', value: '0.5' },
+ { label: '0.75', value: '0.7' },
+ { label: '1.0', value: '1.0' },
+];
+
+const useUpdateAudioMotionAnalyzer = () => {
+ const visualizer = useVisualizerSettings();
+ const { setSettings } = useSettingsStoreActions();
+
+ const updateProperty = (
+ property: K,
+ value: (typeof visualizer.audiomotionanalyzer)[K],
+ ) => {
+ setSettings({
+ visualizer: {
+ ...visualizer,
+ audiomotionanalyzer: {
+ ...visualizer.audiomotionanalyzer,
+ [property]: value,
+ },
+ },
+ });
+ };
+
+ return { updateProperty, visualizer };
+};
+
+const useUpdateButterchurn = () => {
+ const visualizer = useVisualizerSettings();
+ const { setSettings } = useSettingsStoreActions();
+
+ const updateProperty = (
+ property: K,
+ value: (typeof visualizer.butterchurn)[K],
+ ) => {
+ setSettings({
+ visualizer: {
+ ...visualizer,
+ butterchurn: {
+ ...visualizer.butterchurn,
+ [property]: value,
+ },
+ },
+ });
+ };
+
+ return { updateProperty, visualizer };
+};
+
+export const VisualizerSettingsForm = () => {
+ const { t } = useTranslation();
+ const visualizer = useVisualizerSettings();
+ const { setSettings } = useSettingsStoreActions();
+
+ const visualizerTypeOptions = useMemo(
+ () => [
+ { label: 'AudioMotion Analyzer', value: 'audiomotionanalyzer' },
+ { label: 'Butterchurn', value: 'butterchurn' },
+ ],
+ [],
+ );
+
+ const handleTypeChange = (value: string) => {
+ setSettings({
+ visualizer: {
+ ...visualizer,
+ type: value as 'audiomotionanalyzer' | 'butterchurn',
+ },
+ });
+ };
+
+ return (
+
+
+ {visualizer.type === 'audiomotionanalyzer' && (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )}
+ {visualizer.type === 'butterchurn' && (
+ <>
+
+
+ >
+ )}
+
+ );
+};
+
+const VisualizerSelect = (props: SelectProps) => {
+ return ;
+};
+
+const VisualizerSlider = (props: SliderProps & { label?: React.ReactNode }) => {
+ const { defaultValue, label, max, min, onChange, onChangeEnd, step, ...rest } = props;
+
+ const sliderRef = useRef(null);
+ const inputRef = useRef(null);
+ const [value, setValue] = useState((defaultValue as number) ?? 0);
+ const [isEditing, setIsEditing] = useState(false);
+ const [editValue, setEditValue] = useState((defaultValue as number) ?? 0);
+
+ // Update local state when defaultValue changes externally
+ useEffect(() => {
+ if (defaultValue !== undefined) {
+ setValue(defaultValue as number);
+ setEditValue(defaultValue as number);
+ }
+ }, [defaultValue]);
+
+ // Auto-focus input when entering edit mode
+ useEffect(() => {
+ if (isEditing && inputRef.current) {
+ inputRef.current.focus();
+ inputRef.current.select();
+ }
+ }, [isEditing]);
+
+ const handleChange = (val: number) => {
+ setValue(val);
+ onChange?.(val);
+ };
+
+ const handleTextClick = () => {
+ setEditValue(value);
+ setIsEditing(true);
+ };
+
+ const handleInputChange = (val: number | string) => {
+ const numVal = typeof val === 'number' ? val : parseFloat(val) || 0;
+ setEditValue(numVal);
+
+ // Update slider value in real-time as user types (clamped to bounds)
+ let clampedValue = numVal;
+ if (min !== undefined && clampedValue < min) {
+ clampedValue = min;
+ }
+ if (max !== undefined && clampedValue > max) {
+ clampedValue = max;
+ }
+ setValue(clampedValue);
+ onChange?.(clampedValue);
+ };
+
+ const handleInputBlur = () => {
+ applyEditValue();
+ };
+
+ const handleInputKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ applyEditValue();
+ } else if (e.key === 'Escape') {
+ setIsEditing(false);
+ setEditValue(value);
+ }
+ };
+
+ const applyEditValue = () => {
+ let finalValue = editValue;
+
+ // Clamp value to min/max bounds
+ if (min !== undefined && finalValue < min) {
+ finalValue = min;
+ }
+ if (max !== undefined && finalValue > max) {
+ finalValue = max;
+ }
+
+ setValue(finalValue);
+ setEditValue(finalValue);
+ setIsEditing(false);
+
+ // Update slider and trigger onChangeEnd to save
+ onChange?.(finalValue);
+ onChangeEnd?.(finalValue);
+ };
+
+ return (
+
+ {label && (
+
+ {typeof label === 'string' ? (
+
+ {label}
+
+ ) : (
+ label
+ )}
+
+ )}
+
+ {isEditing ? (
+
+ ) : (
+
+ {value.toFixed(step && step < 1 ? 1 : 0)}
+
+ )}
+
+ );
+};
+
+const VisualizerToggle = (props: {
+ disabled?: boolean;
+ label: string;
+ onChange: (value: boolean) => void;
+ value: boolean;
+}) => {
+ const { disabled, label, onChange, value } = props;
+
+ return (
+
+ );
+};
+
+const PresetSettings = () => {
+ const { t } = useTranslation();
+ const visualizer = useVisualizerSettings();
+ const { setSettings } = useSettingsStoreActions();
+ const [selectedPreset, setSelectedPreset] = useState(null);
+ const [isSaving, setIsSaving] = useState(false);
+ const [newPresetName, setNewPresetName] = useState('');
+ const [isPasting, setIsPasting] = useState(false);
+ const [pasteValue, setPasteValue] = useState('');
+
+ const applyPreset = (presetName: null | string) => {
+ if (!presetName) return;
+
+ const preset = visualizer.audiomotionanalyzer.presets.find((p) => p.name === presetName);
+
+ if (!preset) return;
+
+ const initialDefaults = {
+ alphaBars: false,
+ ansiBands: false,
+ barSpace: 0.1,
+ channelLayout: 'single' as const,
+ colorMode: 'gradient' as const,
+ customGradients: [],
+ fadePeaks: false,
+ fftSize: 8192,
+ fillAlpha: 1,
+ frequencyScale: 'log' as const,
+ gradient: 'classic',
+ gradientLeft: undefined,
+ gradientRight: undefined,
+ gravity: 3.8,
+ ledBars: true,
+ linearAmplitude: false,
+ linearBoost: 1.0,
+ lineWidth: 0,
+ loRes: false,
+ lumiBars: false,
+ maxDecibels: -25,
+ maxFPS: 0,
+ maxFreq: 22000,
+ minDecibels: -85,
+ minFreq: 20,
+ mirror: 0.0,
+ mode: 0,
+ noteLabels: false,
+ outlineBars: false,
+ peakFadeTime: 750,
+ peakHoldTime: 500,
+ peakLine: false,
+ radial: false,
+ radialInvert: false,
+ radius: 0.3,
+ reflexAlpha: 0.15,
+ reflexBright: 1.0,
+ reflexFit: true,
+ reflexRatio: 0,
+ roundBars: false,
+ showFPS: false,
+ showPeaks: true,
+ showScaleX: false,
+ showScaleY: false,
+ smoothing: 0.5,
+ spinSpeed: 0.0,
+ splitGradient: false,
+ trueLeds: false,
+ volume: 1.0,
+ weightingFilter: '' as const,
+ };
+
+ // Merge preset values with initial defaults to ensure all properties are included
+ const presetValue = {
+ ...initialDefaults,
+ ...preset.value,
+ };
+
+ setSettings({
+ visualizer: {
+ ...visualizer,
+ audiomotionanalyzer: {
+ ...visualizer.audiomotionanalyzer,
+ ...presetValue,
+ },
+ },
+ });
+ };
+
+ const handlePresetChange = (value: null | string) => {
+ setSelectedPreset(value);
+ if (value) {
+ applyPreset(value);
+ }
+ };
+
+ const handleSavePreset = () => {
+ if (!newPresetName.trim()) return;
+
+ // Check if preset name already exists
+ const existingPreset = visualizer.audiomotionanalyzer.presets.find(
+ (p) => p.name === newPresetName.trim(),
+ );
+
+ if (existingPreset) {
+ // Update existing preset
+ const updatedPresets = visualizer.audiomotionanalyzer.presets.map((p) =>
+ p.name === newPresetName.trim()
+ ? {
+ ...p,
+ value: getCurrentSettingsAsPresetValue(),
+ }
+ : p,
+ );
+
+ setSettings({
+ visualizer: {
+ ...visualizer,
+ audiomotionanalyzer: {
+ ...visualizer.audiomotionanalyzer,
+ presets: updatedPresets,
+ },
+ },
+ });
+ } else {
+ // Add new preset
+ const newPreset = {
+ name: newPresetName.trim(),
+ value: getCurrentSettingsAsPresetValue(),
+ };
+
+ setSettings({
+ visualizer: {
+ ...visualizer,
+ audiomotionanalyzer: {
+ ...visualizer.audiomotionanalyzer,
+ presets: [...visualizer.audiomotionanalyzer.presets, newPreset],
+ },
+ },
+ });
+ }
+
+ setNewPresetName('');
+ setIsSaving(false);
+ setSelectedPreset(newPresetName.trim());
+ };
+
+ const getCurrentSettingsAsPresetValue = () => {
+ return {
+ alphaBars: visualizer.audiomotionanalyzer.alphaBars,
+ ansiBands: visualizer.audiomotionanalyzer.ansiBands,
+ barSpace: visualizer.audiomotionanalyzer.barSpace,
+ channelLayout: visualizer.audiomotionanalyzer.channelLayout,
+ colorMode: visualizer.audiomotionanalyzer.colorMode,
+ fadePeaks: visualizer.audiomotionanalyzer.fadePeaks,
+ fftSize: visualizer.audiomotionanalyzer.fftSize,
+ fillAlpha: visualizer.audiomotionanalyzer.fillAlpha,
+ frequencyScale: visualizer.audiomotionanalyzer.frequencyScale,
+ gradient: visualizer.audiomotionanalyzer.gradient,
+ gradientLeft: visualizer.audiomotionanalyzer.gradientLeft,
+ gradientRight: visualizer.audiomotionanalyzer.gradientRight,
+ gravity: visualizer.audiomotionanalyzer.gravity,
+ ledBars: visualizer.audiomotionanalyzer.ledBars,
+ linearAmplitude: visualizer.audiomotionanalyzer.linearAmplitude,
+ linearBoost: visualizer.audiomotionanalyzer.linearBoost,
+ lineWidth: visualizer.audiomotionanalyzer.lineWidth,
+ loRes: visualizer.audiomotionanalyzer.loRes,
+ lumiBars: visualizer.audiomotionanalyzer.lumiBars,
+ maxDecibels: visualizer.audiomotionanalyzer.maxDecibels,
+ maxFPS: visualizer.audiomotionanalyzer.maxFPS,
+ maxFreq: visualizer.audiomotionanalyzer.maxFreq,
+ minDecibels: visualizer.audiomotionanalyzer.minDecibels,
+ minFreq: visualizer.audiomotionanalyzer.minFreq,
+ mirror: visualizer.audiomotionanalyzer.mirror,
+ mode: visualizer.audiomotionanalyzer.mode,
+ noteLabels: visualizer.audiomotionanalyzer.noteLabels,
+ outlineBars: visualizer.audiomotionanalyzer.outlineBars,
+ peakFadeTime: visualizer.audiomotionanalyzer.peakFadeTime,
+ peakHoldTime: visualizer.audiomotionanalyzer.peakHoldTime,
+ peakLine: visualizer.audiomotionanalyzer.peakLine,
+ radial: visualizer.audiomotionanalyzer.radial,
+ radialInvert: visualizer.audiomotionanalyzer.radialInvert,
+ radius: visualizer.audiomotionanalyzer.radius,
+ reflexAlpha: visualizer.audiomotionanalyzer.reflexAlpha,
+ reflexBright: visualizer.audiomotionanalyzer.reflexBright,
+ reflexFit: visualizer.audiomotionanalyzer.reflexFit,
+ reflexRatio: visualizer.audiomotionanalyzer.reflexRatio,
+ roundBars: visualizer.audiomotionanalyzer.roundBars,
+ showFPS: visualizer.audiomotionanalyzer.showFPS,
+ showPeaks: visualizer.audiomotionanalyzer.showPeaks,
+ showScaleX: visualizer.audiomotionanalyzer.showScaleX,
+ showScaleY: visualizer.audiomotionanalyzer.showScaleY,
+ smoothing: visualizer.audiomotionanalyzer.smoothing,
+ spinSpeed: visualizer.audiomotionanalyzer.spinSpeed,
+ splitGradient: visualizer.audiomotionanalyzer.splitGradient,
+ trueLeds: visualizer.audiomotionanalyzer.trueLeds,
+ volume: visualizer.audiomotionanalyzer.volume,
+ weightingFilter: visualizer.audiomotionanalyzer.weightingFilter,
+ };
+ };
+
+ const handleUpdatePreset = () => {
+ if (!selectedPreset) return;
+
+ const updatedPresets = visualizer.audiomotionanalyzer.presets.map((p) =>
+ p.name === selectedPreset
+ ? {
+ ...p,
+ value: getCurrentSettingsAsPresetValue(),
+ }
+ : p,
+ );
+
+ setSettings({
+ visualizer: {
+ ...visualizer,
+ audiomotionanalyzer: {
+ ...visualizer.audiomotionanalyzer,
+ presets: updatedPresets,
+ },
+ },
+ });
+ };
+
+ const handleDeletePreset = () => {
+ if (!selectedPreset) return;
+
+ const updatedPresets = visualizer.audiomotionanalyzer.presets.filter(
+ (p) => p.name !== selectedPreset,
+ );
+
+ setSettings({
+ visualizer: {
+ ...visualizer,
+ audiomotionanalyzer: {
+ ...visualizer.audiomotionanalyzer,
+ presets: updatedPresets,
+ },
+ },
+ });
+
+ setSelectedPreset(null);
+ };
+
+ const handleCopyConfiguration = async () => {
+ try {
+ const config = getCurrentSettingsAsPresetValue();
+ const configJson = JSON.stringify(config, null, 2);
+ await navigator.clipboard.writeText(configJson);
+ toast.success({
+ message: t('visualizer.configCopied', { postProcess: 'sentenceCase' }),
+ });
+ } catch {
+ toast.error({
+ message: t('visualizer.configCopyFailed', { postProcess: 'sentenceCase' }),
+ });
+ }
+ };
+
+ const handlePasteConfiguration = () => {
+ if (!pasteValue.trim()) return;
+
+ try {
+ const parsed = JSON.parse(pasteValue.trim());
+
+ // Validate that it's an object with expected properties
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
+ throw new Error('Invalid configuration format');
+ }
+
+ // Merge with initial defaults to ensure all properties are set
+ const initialDefaults = {
+ alphaBars: false,
+ ansiBands: false,
+ barSpace: 0.1,
+ channelLayout: 'single' as const,
+ colorMode: 'gradient' as const,
+ customGradients: [],
+ fadePeaks: false,
+ fftSize: 8192,
+ fillAlpha: 1,
+ frequencyScale: 'log' as const,
+ gradient: 'classic',
+ gradientLeft: undefined,
+ gradientRight: undefined,
+ gravity: 3.8,
+ ledBars: true,
+ linearAmplitude: false,
+ linearBoost: 1.0,
+ lineWidth: 0,
+ loRes: false,
+ lumiBars: false,
+ maxDecibels: -25,
+ maxFPS: 0,
+ maxFreq: 22000,
+ minDecibels: -85,
+ minFreq: 20,
+ mirror: 0.0,
+ mode: 0,
+ noteLabels: false,
+ outlineBars: false,
+ peakFadeTime: 750,
+ peakHoldTime: 500,
+ peakLine: false,
+ radial: false,
+ radialInvert: false,
+ radius: 0.3,
+ reflexAlpha: 0.15,
+ reflexBright: 1.0,
+ reflexFit: true,
+ reflexRatio: 0,
+ roundBars: false,
+ showFPS: false,
+ showPeaks: true,
+ showScaleX: false,
+ showScaleY: false,
+ smoothing: 0.5,
+ spinSpeed: 0.0,
+ splitGradient: false,
+ trueLeds: false,
+ volume: 1.0,
+ weightingFilter: '' as const,
+ };
+
+ const configValue = {
+ ...initialDefaults,
+ ...parsed,
+ };
+
+ setSettings({
+ visualizer: {
+ ...visualizer,
+ audiomotionanalyzer: {
+ ...visualizer.audiomotionanalyzer,
+ ...configValue,
+ },
+ },
+ });
+
+ toast.success({
+ message: t('visualizer.configPasted', { postProcess: 'sentenceCase' }),
+ });
+
+ setPasteValue('');
+ setIsPasting(false);
+ } catch {
+ toast.error({
+ message: t('visualizer.configPasteFailed', { postProcess: 'sentenceCase' }),
+ });
+ }
+ };
+
+ const handlePasteFromClipboard = async () => {
+ try {
+ const text = await navigator.clipboard.readText();
+ setPasteValue(text);
+ setIsPasting(true);
+ } catch {
+ toast.error({
+ message: t('visualizer.configPasteReadFailed', { postProcess: 'sentenceCase' }),
+ });
+ }
+ };
+
+ const presetOptions = useMemo(() => {
+ return visualizer.audiomotionanalyzer.presets.map((preset) => ({
+ label: preset.name,
+ value: preset.name,
+ }));
+ }, [visualizer.audiomotionanalyzer.presets]);
+
+ return (
+
+ );
+};
+
+const GeneralSettings = () => {
+ const { t } = useTranslation();
+ const { updateProperty, visualizer } = useUpdateAudioMotionAnalyzer();
+
+ const isMode18Disabled = visualizer.audiomotionanalyzer.mode > 8;
+ const isMode10Disabled = visualizer.audiomotionanalyzer.mode !== 10;
+
+ const getModeKey = (value: string) => {
+ const modeMap: Record = {
+ '0': 'bars',
+ '1': 'circle',
+ '2': 'wave',
+ '3': 'rainbow',
+ '4': 'rings',
+ '5': 'mirror',
+ '6': 'line',
+ '7': 'particles',
+ '8': 'fullOctave',
+ '10': 'outlineBars',
+ };
+ return modeMap[value] || 'bars';
+ };
+
+ const translatedModeOptions = useMemo(
+ () =>
+ modeOptions.map((option) => {
+ const value = option.value as string;
+ return {
+ label: t(`visualizer.options.mode.${getModeKey(value)}`),
+ value,
+ };
+ }),
+ [t],
+ );
+
+ const getChannelLayoutKey = (value: string) => {
+ const layoutMap: Record = {
+ 'dual-combined': 'dualCombined',
+ 'dual-horizontal': 'dualHorizontal',
+ 'dual-vertical': 'dualVertical',
+ single: 'single',
+ };
+ return layoutMap[value] || 'single';
+ };
+
+ const translatedChannelLayoutOptions = useMemo(
+ () =>
+ channelLayoutOptions.map((option) => {
+ const value = option.value || 'single';
+ return {
+ label: t(`visualizer.options.channelLayout.${getChannelLayoutKey(value)}`),
+ value: value as string,
+ };
+ }),
+ [t],
+ );
+
+ return (
+
+ );
+};
+
+type CustomGradient = {
+ colorStops: (string | { color: string; level?: number; pos?: number })[];
+ dir?: string;
+ name: string;
+};
+
+const CustomGradientsManager = () => {
+ const { t } = useTranslation();
+ const { updateProperty, visualizer } = useUpdateAudioMotionAnalyzer();
+ const [isAdding, setIsAdding] = useState(false);
+ const [editingIndex, setEditingIndex] = useState(null);
+ const [newGradient, setNewGradient] = useState({
+ colorStops: ['#ff0000'],
+ dir: 'v',
+ name: '',
+ });
+ // Track which checkboxes are enabled for each color stop
+ const [colorStopOptions, setColorStopOptions] = useState<
+ Array<{ enableLevel: boolean; enablePos: boolean }>
+ >([{ enableLevel: false, enablePos: false }]);
+
+ const customGradients = visualizer.audiomotionanalyzer.customGradients || [];
+
+ const handleAddGradient = () => {
+ if (!newGradient.name.trim()) return;
+
+ const updatedGradients = [...customGradients, newGradient];
+ updateProperty('customGradients', updatedGradients);
+ setNewGradient({ colorStops: ['#ff0000'], dir: 'v', name: '' });
+ setColorStopOptions([{ enableLevel: false, enablePos: false }]);
+ setIsAdding(false);
+ };
+
+ const handleDeleteGradient = (index: number) => {
+ const updatedGradients = customGradients.filter((_, i) => i !== index);
+ updateProperty('customGradients', updatedGradients);
+ };
+
+ const handleEditGradient = (index: number) => {
+ const gradient = customGradients[index];
+ setNewGradient(gradient);
+ // Initialize checkbox states based on existing color stops
+ const options = gradient.colorStops.map((stop) => ({
+ enableLevel: typeof stop !== 'string' && stop.level !== undefined,
+ enablePos: typeof stop !== 'string' && stop.pos !== undefined,
+ }));
+ setColorStopOptions(options);
+ setEditingIndex(index);
+ setIsAdding(true);
+ };
+
+ const handleSaveEdit = () => {
+ if (!newGradient.name.trim() || editingIndex === null) return;
+
+ const updatedGradients = [...customGradients];
+ updatedGradients[editingIndex] = newGradient;
+ updateProperty('customGradients', updatedGradients);
+ setNewGradient({ colorStops: ['#ff0000'], dir: 'v', name: '' });
+ setColorStopOptions([{ enableLevel: false, enablePos: false }]);
+ setEditingIndex(null);
+ setIsAdding(false);
+ };
+
+ const handleCancel = () => {
+ setNewGradient({ colorStops: ['#ff0000'], dir: 'v', name: '' });
+ setColorStopOptions([{ enableLevel: false, enablePos: false }]);
+ setEditingIndex(null);
+ setIsAdding(false);
+ };
+
+ const handleAddColorStop = () => {
+ setNewGradient({
+ ...newGradient,
+ colorStops: [...newGradient.colorStops, '#00ff00'],
+ });
+ setColorStopOptions([...colorStopOptions, { enableLevel: false, enablePos: false }]);
+ };
+
+ const handleRemoveColorStop = (index: number) => {
+ if (newGradient.colorStops.length <= 1) return;
+ setNewGradient({
+ ...newGradient,
+ colorStops: newGradient.colorStops.filter((_, i) => i !== index),
+ });
+ setColorStopOptions(colorStopOptions.filter((_, i) => i !== index));
+ };
+
+ const handleColorStopChange = (index: number, color: string) => {
+ const updatedColorStops = [...newGradient.colorStops];
+ const currentStop = updatedColorStops[index];
+ const options = colorStopOptions[index];
+
+ // If neither checkbox is enabled, store as string
+ if (!options.enablePos && !options.enableLevel) {
+ updatedColorStops[index] = color;
+ } else {
+ // Otherwise, store as object with enabled properties
+ updatedColorStops[index] = {
+ color,
+ ...(options.enablePos &&
+ typeof currentStop !== 'string' &&
+ currentStop.pos !== undefined
+ ? { pos: currentStop.pos }
+ : {}),
+ ...(options.enableLevel &&
+ typeof currentStop !== 'string' &&
+ currentStop.level !== undefined
+ ? { level: currentStop.level }
+ : {}),
+ };
+ }
+
+ setNewGradient({ ...newGradient, colorStops: updatedColorStops });
+ };
+
+ const handleColorStopPosChange = (index: number, pos: number | string) => {
+ const updatedColorStops = [...newGradient.colorStops];
+ const currentStop = updatedColorStops[index];
+ const posValue = typeof pos === 'number' ? pos : parseFloat(pos) || undefined;
+ const options = colorStopOptions[index];
+
+ const color = typeof currentStop === 'string' ? currentStop : currentStop.color;
+
+ updatedColorStops[index] = {
+ color,
+ ...(options.enablePos && posValue !== undefined ? { pos: posValue } : {}),
+ ...(options.enableLevel &&
+ typeof currentStop !== 'string' &&
+ currentStop.level !== undefined
+ ? { level: currentStop.level }
+ : {}),
+ };
+
+ setNewGradient({ ...newGradient, colorStops: updatedColorStops });
+ };
+
+ const handleColorStopLevelChange = (index: number, level: number | string) => {
+ const updatedColorStops = [...newGradient.colorStops];
+ const currentStop = updatedColorStops[index];
+ const levelValue = typeof level === 'number' ? level : parseFloat(level) || undefined;
+ const options = colorStopOptions[index];
+
+ const color = typeof currentStop === 'string' ? currentStop : currentStop.color;
+
+ updatedColorStops[index] = {
+ color,
+ ...(options.enablePos &&
+ typeof currentStop !== 'string' &&
+ currentStop.pos !== undefined
+ ? { pos: currentStop.pos }
+ : {}),
+ ...(options.enableLevel && levelValue !== undefined ? { level: levelValue } : {}),
+ };
+
+ setNewGradient({ ...newGradient, colorStops: updatedColorStops });
+ };
+
+ const handleTogglePos = (index: number, enabled: boolean) => {
+ const updatedOptions = [...colorStopOptions];
+ updatedOptions[index] = { ...updatedOptions[index], enablePos: enabled };
+ setColorStopOptions(updatedOptions);
+
+ // If both are now disabled, convert to string
+ if (!enabled && !updatedOptions[index].enableLevel) {
+ const updatedColorStops = [...newGradient.colorStops];
+ const currentStop = updatedColorStops[index];
+ const color = typeof currentStop === 'string' ? currentStop : currentStop.color;
+ updatedColorStops[index] = color;
+ setNewGradient({ ...newGradient, colorStops: updatedColorStops });
+ } else {
+ // Otherwise, ensure it's an object
+ const updatedColorStops = [...newGradient.colorStops];
+ const currentStop = updatedColorStops[index];
+ const color = typeof currentStop === 'string' ? currentStop : currentStop.color;
+
+ updatedColorStops[index] = {
+ color,
+ ...(enabled && typeof currentStop !== 'string' && currentStop.pos !== undefined
+ ? { pos: currentStop.pos }
+ : {}),
+ ...(updatedOptions[index].enableLevel &&
+ typeof currentStop !== 'string' &&
+ currentStop.level !== undefined
+ ? { level: currentStop.level }
+ : {}),
+ };
+ setNewGradient({ ...newGradient, colorStops: updatedColorStops });
+ }
+ };
+
+ const handleToggleLevel = (index: number, enabled: boolean) => {
+ const updatedOptions = [...colorStopOptions];
+ updatedOptions[index] = { ...updatedOptions[index], enableLevel: enabled };
+ setColorStopOptions(updatedOptions);
+
+ // If both are now disabled, convert to string
+ if (!enabled && !updatedOptions[index].enablePos) {
+ const updatedColorStops = [...newGradient.colorStops];
+ const currentStop = updatedColorStops[index];
+ const color = typeof currentStop === 'string' ? currentStop : currentStop.color;
+ updatedColorStops[index] = color;
+ setNewGradient({ ...newGradient, colorStops: updatedColorStops });
+ } else {
+ // Otherwise, ensure it's an object
+ const updatedColorStops = [...newGradient.colorStops];
+ const currentStop = updatedColorStops[index];
+ const color = typeof currentStop === 'string' ? currentStop : currentStop.color;
+
+ updatedColorStops[index] = {
+ color,
+ ...(updatedOptions[index].enablePos &&
+ typeof currentStop !== 'string' &&
+ currentStop.pos !== undefined
+ ? { pos: currentStop.pos }
+ : {}),
+ ...(enabled && typeof currentStop !== 'string' && currentStop.level !== undefined
+ ? { level: currentStop.level }
+ : {}),
+ };
+ setNewGradient({ ...newGradient, colorStops: updatedColorStops });
+ }
+ };
+
+ return (
+
+ {t('visualizer.customGradients')}
+
+
+ }
+ >
+
+ {customGradients.length > 0 && (
+
+ {customGradients.map((gradient, index) => (
+
+
+ {gradient.name}
+
+
+
+
+ ))}
+
+ )}
+
+ {!isAdding ? (
+
+ ) : (
+ <>
+
+
+
+ setNewGradient({ ...newGradient, name: e.currentTarget.value })
+ }
+ placeholder={t('visualizer.gradientNamePlaceholder')}
+ size="sm"
+ value={newGradient.name}
+ />
+
+ setNewGradient({
+ ...newGradient,
+ dir: value,
+ })
+ }
+ size="sm"
+ value={newGradient.dir || 'v'}
+ />
+
+
+ {t('visualizer.colorStops')}
+
+
+ {newGradient.colorStops.map((stop, index) => {
+ const options = colorStopOptions[index] || {
+ enableLevel: false,
+ enablePos: false,
+ };
+ return (
+
+
+ handleColorStopChange(index, color)
+ }
+ size="sm"
+ value={typeof stop === 'string' ? stop : stop.color}
+ />
+
+
+ handleTogglePos(
+ index,
+ e.currentTarget.checked,
+ )
+ }
+ size="xs"
+ />
+
+ {t('visualizer.position')}
+
+
+ }
+ max={1}
+ min={0}
+ onChangeEnd={(e) =>
+ handleColorStopPosChange(index, e)
+ }
+ step={0.1}
+ />
+
+
+ handleToggleLevel(
+ index,
+ e.currentTarget.checked,
+ )
+ }
+ size="xs"
+ />
+
+ {t('visualizer.level')}
+
+
+ }
+ max={1}
+ min={0}
+ onChangeEnd={(e) =>
+ handleColorStopLevelChange(index, e)
+ }
+ step={0.1}
+ />
+ {newGradient.colorStops.length > 1 && (
+
+ )}
+
+ );
+ })}
+
+
+
+
+
+
+ >
+ )}
+
+
+ );
+};
+
+const ColorSettings = () => {
+ const { t } = useTranslation();
+ const { updateProperty, visualizer } = useUpdateAudioMotionAnalyzer();
+
+ const isGradientDisabled = visualizer.audiomotionanalyzer.channelLayout !== 'single';
+ const isGradientLeftDisabled = visualizer.audiomotionanalyzer.channelLayout === 'single';
+ const isGradientRightDisabled = visualizer.audiomotionanalyzer.channelLayout === 'single';
+
+ const getColorModeKey = (value: string) => {
+ const colorModeMap: Record = {
+ 'bar-index': 'barIndex',
+ 'bar-level': 'barLevel',
+ gradient: 'gradient',
+ };
+ return colorModeMap[value] || 'gradient';
+ };
+
+ const translatedColorModeOptions = useMemo(
+ () =>
+ colorModeOptions.map((option) => {
+ const value = option.value || 'gradient';
+ return {
+ label: t(`visualizer.options.colorMode.${getColorModeKey(value)}`),
+ value: value as string,
+ };
+ }),
+ [t],
+ );
+
+ const translatedGradientOptions = useMemo(
+ () =>
+ gradientOptions.map((option) => ({
+ label: t(`visualizer.options.gradient.${option.value}`),
+ value: option.value as string,
+ })),
+ [t],
+ );
+
+ const allGradientOptions = useMemo(
+ () => [
+ {
+ group: t('visualizer.custom'),
+ items: (visualizer.audiomotionanalyzer.customGradients || []).map((gradient) => ({
+ label: gradient.name,
+ value: gradient.name,
+ })),
+ },
+ {
+ group: t('visualizer.builtIn'),
+ items: translatedGradientOptions,
+ },
+ ],
+ [t, translatedGradientOptions, visualizer.audiomotionanalyzer.customGradients],
+ );
+
+ return (
+
+
+
+
+ updateProperty(
+ 'colorMode',
+ (e || 'gradient') as 'bar-index' | 'bar-level' | 'gradient',
+ )
+ }
+ />
+
+ updateProperty(
+ 'gradient',
+ (e || 'classic') as typeof visualizer.audiomotionanalyzer.gradient,
+ )
+ }
+ />
+
+
+
+ updateProperty(
+ 'gradientLeft',
+ (e ||
+ 'classic') as typeof visualizer.audiomotionanalyzer.gradientLeft,
+ )
+ }
+ />
+
+ updateProperty(
+ 'gradientRight',
+ (e ||
+ 'classic') as typeof visualizer.audiomotionanalyzer.gradientRight,
+ )
+ }
+ />
+
+
+
+
+ );
+};
+
+const FFTSettings = () => {
+ const { t } = useTranslation();
+ const { updateProperty, visualizer } = useUpdateAudioMotionAnalyzer();
+
+ return (
+
+
+ ({
+ label: option.label,
+ value: option.value as string,
+ }))}
+ defaultValue={visualizer.audiomotionanalyzer.fftSize.toString()}
+ label={t('visualizer.fftSize')}
+ onChange={(e) => updateProperty('fftSize', Number(e))}
+ />
+ updateProperty('smoothing', e)}
+ step={0.1}
+ />
+
+
+ );
+};
+
+const FrequencySettings = () => {
+ const { t } = useTranslation();
+ const { updateProperty, visualizer } = useUpdateAudioMotionAnalyzer();
+
+ const translatedFrequencyScaleOptions = useMemo(
+ () =>
+ frequencyScaleOptions.map((option) => ({
+ label: t(`visualizer.options.frequencyScale.${option.value}`),
+ value: option.value as string,
+ })),
+ [t],
+ );
+
+ return (
+
+
+ ({
+ label: option.label,
+ value: option.value as string,
+ }))}
+ defaultValue={visualizer.audiomotionanalyzer.minFreq.toString()}
+ label={t('visualizer.minimumFrequency')}
+ onChange={(e) => updateProperty('minFreq', Number(e))}
+ />
+ ({
+ label: option.label,
+ value: option.value as string,
+ }))}
+ defaultValue={visualizer.audiomotionanalyzer.maxFreq.toString()}
+ label={t('visualizer.maximumFrequency')}
+ onChange={(e) => updateProperty('maxFreq', Number(e))}
+ />
+
+ updateProperty(
+ 'frequencyScale',
+ (e || 'log') as 'bark' | 'linear' | 'log' | 'mel',
+ )
+ }
+ />
+
+
+ );
+};
+
+const SensitivitySettings = () => {
+ const { t } = useTranslation();
+ const { updateProperty, visualizer } = useUpdateAudioMotionAnalyzer();
+
+ const getWeightingFilterKey = (value: string) => {
+ return value === '' ? 'none' : value.toLowerCase();
+ };
+
+ const translatedWeightingFilterOptions = useMemo(
+ () =>
+ weightingFilterOptions.map((option) => ({
+ label: t(
+ `visualizer.options.weightingFilter.${getWeightingFilterKey(option.value)}`,
+ ),
+ value: option.value as string,
+ })),
+ [t],
+ );
+
+ return (
+
+
+
+ updateProperty('weightingFilter', e as 'A' | 'B' | 'C' | 'D' | 'Z')
+ }
+ />
+ updateProperty('minDecibels', e)}
+ step={1}
+ />
+ updateProperty('maxDecibels', e)}
+ step={1}
+ />
+
+
+ );
+};
+
+const LinearAmplitudeSettings = () => {
+ const { t } = useTranslation();
+ const { updateProperty, visualizer } = useUpdateAudioMotionAnalyzer();
+
+ const isLinearBoostDisabled = !visualizer.audiomotionanalyzer.linearAmplitude;
+
+ return (
+
+
+ updateProperty('linearAmplitude', value)}
+ value={visualizer.audiomotionanalyzer.linearAmplitude}
+ />
+ updateProperty('linearBoost', e)}
+ step={0.1}
+ />
+
+
+ );
+};
+
+const PeakBehaviorSettings = () => {
+ const { t } = useTranslation();
+ const { updateProperty, visualizer } = useUpdateAudioMotionAnalyzer();
+
+ const peakToggles = useMemo(
+ () => [
+ { label: t('visualizer.showPeaks'), value: 'showPeaks' },
+ { label: t('visualizer.fadePeaks'), value: 'fadePeaks' },
+ { label: t('visualizer.peakLine'), value: 'peakLine' },
+ ],
+ [t],
+ );
+
+ const isFadePeaksDisabled = !visualizer.audiomotionanalyzer.showPeaks;
+ const isPeakLineDisabled = !visualizer.audiomotionanalyzer.showPeaks;
+ const isGravityDisabled = !visualizer.audiomotionanalyzer.showPeaks;
+ const isPeakFadeTimeDisabled =
+ !visualizer.audiomotionanalyzer.showPeaks || !visualizer.audiomotionanalyzer.fadePeaks;
+ const isPeakHoldTimeDisabled = !visualizer.audiomotionanalyzer.showPeaks;
+
+ const isToggleDisabled = (toggle: (typeof peakToggles)[number]) => {
+ if (toggle.value === 'fadePeaks') return isFadePeaksDisabled;
+ if (toggle.value === 'peakLine') return isPeakLineDisabled;
+ return false;
+ };
+
+ return (
+
+
+
+ {peakToggles.map((toggle) => (
+
+ updateProperty(
+ toggle.value as keyof typeof visualizer.audiomotionanalyzer,
+ value,
+ )
+ }
+ value={visualizer.audiomotionanalyzer[toggle.value]}
+ />
+ ))}
+
+
+ updateProperty('gravity', e)}
+ />
+ updateProperty('peakFadeTime', e)}
+ step={1}
+ />
+ updateProperty('peakHoldTime', e)}
+ step={1}
+ />
+
+
+
+ );
+};
+
+const RadialSpectrumSettings = () => {
+ const { t } = useTranslation();
+ const { updateProperty, visualizer } = useUpdateAudioMotionAnalyzer();
+
+ const isRadialInvertDisabled = !visualizer.audiomotionanalyzer.radial;
+ const isRadiusDisabled = !visualizer.audiomotionanalyzer.radial;
+ const isReflexAlphaDisabled = !visualizer.audiomotionanalyzer.radial;
+
+ return (
+
+
+ updateProperty('radial', value)}
+ value={visualizer.audiomotionanalyzer.radial}
+ />
+ updateProperty('radialInvert', value)}
+ value={visualizer.audiomotionanalyzer.radialInvert}
+ />
+ updateProperty('radius', e)}
+ step={0.05}
+ />
+ updateProperty('reflexAlpha', e)}
+ step={0.1}
+ />
+
+
+ );
+};
+
+const ReflexMirrorSettings = () => {
+ const { t } = useTranslation();
+ const { updateProperty, visualizer } = useUpdateAudioMotionAnalyzer();
+
+ return (
+
+
+ updateProperty('reflexFit', value)}
+ value={visualizer.audiomotionanalyzer.reflexFit}
+ />
+ updateProperty('reflexRatio', e)}
+ step={0.1}
+ />
+ updateProperty('reflexAlpha', e)}
+ step={0.05}
+ />
+ updateProperty('reflexBright', e)}
+ step={0.1}
+ />
+ updateProperty('mirror', e)}
+ step={1}
+ />
+
+
+ );
+};
+
+const ToggleSettings = () => {
+ const { t } = useTranslation();
+ const { updateProperty, visualizer } = useUpdateAudioMotionAnalyzer();
+
+ const AMA_TOGGLES = useMemo(
+ () => [
+ { label: t('visualizer.alphaBars'), value: 'alphaBars' },
+ { label: t('visualizer.ansiBands'), value: 'ansiBands' },
+ { label: t('visualizer.ledBars'), value: 'ledBars' },
+ { label: t('visualizer.trueLeds'), value: 'trueLeds' },
+ { label: t('visualizer.lumiBars'), value: 'lumiBars' },
+ { label: t('visualizer.outlineBars'), value: 'outlineBars' },
+ { label: t('visualizer.roundBars'), value: 'roundBars' },
+ { label: t('visualizer.lowResolution'), value: 'loRes' },
+ { label: t('visualizer.splitGradient'), value: 'splitGradient' },
+ { label: t('visualizer.showFPS'), value: 'showFPS' },
+ { label: t('visualizer.showScaleX'), value: 'showScaleX' },
+ { label: t('visualizer.noteLabels'), value: 'noteLabels' },
+ { label: t('visualizer.showScaleY'), value: 'showScaleY' },
+ ],
+ [t],
+ );
+
+ const isToggleDisabled = (toggle: (typeof AMA_TOGGLES)[number]) => {
+ if (toggle.value === 'ledBars') return visualizer.audiomotionanalyzer.radial;
+ if (toggle.value === 'trueLeds') return visualizer.audiomotionanalyzer.radial;
+ if (toggle.value === 'lumiBars') return visualizer.audiomotionanalyzer.radial;
+ if (toggle.value === 'noteLabels') return !visualizer.audiomotionanalyzer.showScaleX;
+ if (toggle.value === 'outlineBars') return visualizer.audiomotionanalyzer.radial;
+ if (toggle.value === 'roundBars') return visualizer.audiomotionanalyzer.radial;
+ if (toggle.value === 'loRes') return visualizer.audiomotionanalyzer.radial;
+ if (toggle.value === 'splitGradient') return visualizer.audiomotionanalyzer.radial;
+ if (toggle.value === 'showFPS') return visualizer.audiomotionanalyzer.radial;
+ return false;
+ };
+
+ return (
+
+
+ {AMA_TOGGLES.map((toggle) => (
+
+ updateProperty(
+ toggle.value as keyof typeof visualizer.audiomotionanalyzer,
+ value,
+ )
+ }
+ value={
+ visualizer.audiomotionanalyzer[
+ toggle.value as keyof typeof visualizer.audiomotionanalyzer
+ ] as boolean
+ }
+ />
+ ))}
+
+
+ );
+};
+
+const ButterchurnGeneralSettings = () => {
+ const { t } = useTranslation();
+ const { updateProperty, visualizer } = useUpdateButterchurn();
+
+ const presetOptions = useMemo(() => {
+ const presets = butterchurnPresets.getPresets();
+ return Object.keys(presets).map((presetName) => ({
+ label: presetName,
+ value: presetName,
+ }));
+ }, []);
+
+ return (
+
+
+
+ {
+ updateProperty('currentPreset', value || undefined);
+ }}
+ value={visualizer.butterchurn.currentPreset}
+ />
+
+
+ updateProperty('blendTime', e)}
+ step={0.1}
+ />
+ updateProperty('maxFPS', e)}
+ step={1}
+ />
+
+
+
+ );
+};
+
+const ButterChurnCycleSettings = () => {
+ const { t } = useTranslation();
+ const { updateProperty, visualizer } = useUpdateButterchurn();
+
+ const presetOptions = useMemo(() => {
+ const presets = butterchurnPresets.getPresets();
+ return Object.keys(presets).map((presetName) => ({
+ label: presetName,
+ value: presetName,
+ }));
+ }, []);
+
+ return (
+
+
+
+ updateProperty('cyclePresets', checked)}
+ value={visualizer.butterchurn.cyclePresets}
+ />
+ updateProperty('includeAllPresets', checked)}
+ value={visualizer.butterchurn.includeAllPresets}
+ />
+ updateProperty('randomizeNextPreset', checked)}
+ value={visualizer.butterchurn.randomizeNextPreset}
+ />
+
+ updateProperty('selectedPresets', values)}
+ value={visualizer.butterchurn.selectedPresets}
+ />
+
+
+ updateProperty('cycleTime', e)}
+ step={1}
+ />
+
+
+
+ );
+};
diff --git a/src/renderer/features/visualizer/components/audiomotionanalyzer/visualizer-settings-modal.tsx b/src/renderer/features/visualizer/components/audiomotionanalyzer/visualizer-settings-modal.tsx
new file mode 100644
index 000000000..6bfabf6f9
--- /dev/null
+++ b/src/renderer/features/visualizer/components/audiomotionanalyzer/visualizer-settings-modal.tsx
@@ -0,0 +1,5 @@
+import { VisualizerSettingsForm } from './visualizer-settings-form';
+
+export const VisualizerSettingsContextModal = () => {
+ return ;
+};
diff --git a/src/renderer/features/visualizer/components/audiomotionanalyzer/visualizer.module.css b/src/renderer/features/visualizer/components/audiomotionanalyzer/visualizer.module.css
new file mode 100644
index 000000000..210cc5a97
--- /dev/null
+++ b/src/renderer/features/visualizer/components/audiomotionanalyzer/visualizer.module.css
@@ -0,0 +1,28 @@
+.container {
+ position: relative;
+ z-index: 50;
+ width: 100%;
+ height: 100%;
+ margin: auto;
+
+ canvas {
+ width: 100%;
+ margin: auto;
+ }
+
+ &:hover {
+ .settings-icon {
+ opacity: 1;
+ }
+ }
+}
+
+.container .settings-icon {
+ opacity: 0;
+ transition: opacity 0.2s ease-in-out;
+}
+
+.visualizer {
+ width: 100%;
+ height: 100%;
+}
diff --git a/src/renderer/features/visualizer/components/audiomotionanalyzer/visualizer.tsx b/src/renderer/features/visualizer/components/audiomotionanalyzer/visualizer.tsx
new file mode 100644
index 000000000..b9a54b97b
--- /dev/null
+++ b/src/renderer/features/visualizer/components/audiomotionanalyzer/visualizer.tsx
@@ -0,0 +1,243 @@
+import AudioMotionAnalyzer from 'audiomotion-analyzer';
+import { createRef, useCallback, useEffect, useMemo, useState } from 'react';
+
+import styles from './visualizer.module.css';
+
+import { useWebAudio } from '/@/renderer/features/player/hooks/use-webaudio';
+import { openVisualizerSettingsModal } from '/@/renderer/features/player/utils/open-visualizer-settings-modal';
+import { ComponentErrorBoundary } from '/@/renderer/features/shared/components/component-error-boundary';
+import { useSettingsStore } from '/@/renderer/store';
+import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
+
+const VisualizerInner = () => {
+ const { webAudio } = useWebAudio();
+ const canvasRef = createRef();
+ const accent = useSettingsStore((store) => store.general.accent);
+ const visualizer = useSettingsStore((store) => store.visualizer);
+ const [motion, setMotion] = useState();
+
+ // Check if a gradient name is a custom gradient
+ const isCustomGradient = useCallback(
+ (gradientName: string | undefined): boolean => {
+ if (!gradientName || visualizer.type !== 'audiomotionanalyzer') {
+ return false;
+ }
+
+ const customGradients = visualizer.audiomotionanalyzer.customGradients || [];
+ return customGradients.some((gradient) => gradient.name === gradientName);
+ },
+ [visualizer],
+ );
+
+ const [gradientsRegistered, setGradientsRegistered] = useState(false);
+
+ const options = useMemo(() => {
+ if (visualizer.type !== 'audiomotionanalyzer') {
+ return {};
+ }
+
+ const ama = visualizer.audiomotionanalyzer;
+
+ const defaults = {
+ bgAlpha: 0,
+ showBgColor: false,
+ };
+
+ const gradients: { gradient?: string; gradientLeft?: string; gradientRight?: string } = {};
+
+ // Use default gradient if custom gradient is selected but not yet registered
+ const getSafeGradient = (gradientName: string | undefined): string => {
+ if (!gradientName) return 'classic';
+ if (isCustomGradient(gradientName)) {
+ // Use default until custom gradients are registered
+ return gradientsRegistered ? gradientName : 'classic';
+ }
+ return gradientName;
+ };
+
+ if (ama.channelLayout === 'single') {
+ gradients.gradient = getSafeGradient(ama.gradient);
+ } else {
+ gradients.gradientLeft = getSafeGradient(ama.gradientLeft);
+ gradients.gradientRight = getSafeGradient(ama.gradientRight);
+ }
+
+ return {
+ ...defaults,
+ ...gradients,
+ alphaBars: ama.alphaBars,
+ ansiBands: ama.ansiBands,
+ barSpace: ama.barSpace,
+ channelLayout: ama.channelLayout,
+ colorMode: ama.colorMode,
+ connectSpeakers: false,
+ fadePeaks: ama.fadePeaks,
+ fftSize: ama.fftSize,
+ fillAlpha: ama.fillAlpha,
+ frequencyScale: ama.frequencyScale,
+ gravity: ama.gravity,
+ ledBars: ama.ledBars,
+ linearAmplitude: ama.linearAmplitude,
+ linearBoost: ama.linearBoost,
+ lineWidth: ama.lineWidth,
+ loRes: ama.loRes,
+ lumiBars: ama.lumiBars,
+ maxDecibels: ama.maxDecibels,
+ maxFPS: ama.maxFPS,
+ maxFreq: ama.maxFreq,
+ minDecibels: ama.minDecibels,
+ minFreq: ama.minFreq,
+ mirror: ama.mirror,
+ mode: ama.mode,
+ noteLabels: ama.noteLabels,
+ outlineBars: ama.outlineBars,
+ overlay: true,
+ peakFadeTime: ama.peakFadeTime,
+ peakHoldTime: ama.peakHoldTime,
+ peakLine: ama.peakLine,
+ radial: ama.radial,
+ radialInvert: ama.radialInvert,
+ radius: ama.radius,
+ reflexAlpha: ama.reflexAlpha,
+ reflexBright: ama.reflexBright,
+ reflexFit: ama.reflexFit,
+ reflexRatio: ama.reflexRatio,
+ roundBars: ama.roundBars,
+ showFPS: ama.showFPS,
+ showPeaks: ama.showPeaks,
+ showScaleX: ama.showScaleX,
+ showScaleY: ama.showScaleY,
+ smoothing: ama.smoothing,
+ spinSpeed: ama.spinSpeed,
+ splitGradient: ama.splitGradient,
+ trueLeds: ama.trueLeds,
+ volume: ama.volume,
+ weightingFilter: (ama.weightingFilter || '') as any,
+ };
+ }, [visualizer, gradientsRegistered, isCustomGradient]);
+
+ const registerCustomGradients = useCallback(
+ (audioMotionInstance: AudioMotionAnalyzer) => {
+ if (visualizer.type !== 'audiomotionanalyzer') {
+ return;
+ }
+
+ const customGradients = visualizer.audiomotionanalyzer.customGradients || [];
+
+ customGradients.forEach((gradient) => {
+ try {
+ const gradientConfig: {
+ colorStops: (string | { color: string; level?: number; pos?: number })[];
+ dir?: string;
+ } = {
+ colorStops: gradient.colorStops,
+ };
+
+ if (gradient.dir) {
+ gradientConfig.dir = gradient.dir;
+ }
+
+ // Type assertion needed as TypeScript definitions may be incomplete
+ audioMotionInstance.registerGradient(gradient.name, gradientConfig as any);
+ } catch (error) {
+ console.error(`Failed to register gradient "${gradient.name}":`, error);
+ }
+ });
+
+ // Mark gradients as registered
+ setGradientsRegistered(true);
+ },
+ [visualizer],
+ );
+
+ useEffect(() => {
+ const { context, gains } = webAudio || {};
+ if (gains && context && canvasRef.current && !motion) {
+ // Reset gradients registered flag on new instance
+ setGradientsRegistered(false);
+
+ // Create options without custom gradients on first init
+ const initOptions: any = { ...options };
+
+ // Replace custom gradients with default 'classic' for initial setup
+ if (visualizer.type === 'audiomotionanalyzer') {
+ const ama = visualizer.audiomotionanalyzer;
+ if (isCustomGradient(ama.gradient)) {
+ initOptions.gradient = 'classic';
+ }
+ if (isCustomGradient(ama.gradientLeft)) {
+ initOptions.gradientLeft = 'classic';
+ }
+ if (isCustomGradient(ama.gradientRight)) {
+ initOptions.gradientRight = 'classic';
+ }
+ }
+
+ const audioMotion = new AudioMotionAnalyzer(canvasRef.current, {
+ ...initOptions,
+ audioCtx: context,
+ });
+
+ // Register custom gradients (this will set gradientsRegistered to true)
+ registerCustomGradients(audioMotion);
+
+ setMotion(audioMotion);
+ for (const gain of gains) audioMotion.connectInput(gain);
+ }
+
+ return () => {};
+ }, [
+ accent,
+ canvasRef,
+ motion,
+ registerCustomGradients,
+ webAudio,
+ visualizer,
+ options,
+ isCustomGradient,
+ ]);
+
+ // Re-register custom gradients when they change
+ useEffect(() => {
+ if (motion && visualizer.type === 'audiomotionanalyzer') {
+ setGradientsRegistered(false);
+ registerCustomGradients(motion);
+ }
+ }, [
+ motion,
+ registerCustomGradients,
+ visualizer.audiomotionanalyzer.customGradients,
+ visualizer.type,
+ ]);
+
+ // Update visualizer settings when they change
+ useEffect(() => {
+ if (motion) {
+ motion.setOptions(options);
+ }
+ }, [motion, options]);
+
+ return (
+
+ );
+};
+
+export const Visualizer = () => {
+ return (
+
+
+
+ );
+};
diff --git a/src/renderer/features/visualizer/components/butternchurn/butterchurn.d.ts b/src/renderer/features/visualizer/components/butternchurn/butterchurn.d.ts
new file mode 100644
index 000000000..ac3ef8460
--- /dev/null
+++ b/src/renderer/features/visualizer/components/butternchurn/butterchurn.d.ts
@@ -0,0 +1,7 @@
+declare module 'butterchurn' {
+ export default butterchurn;
+}
+
+declare module 'butterchurn-presets' {
+ export default butterchurnPresets;
+}
diff --git a/src/renderer/features/visualizer/components/butternchurn/visualizer.module.css b/src/renderer/features/visualizer/components/butternchurn/visualizer.module.css
new file mode 100644
index 000000000..045b92929
--- /dev/null
+++ b/src/renderer/features/visualizer/components/butternchurn/visualizer.module.css
@@ -0,0 +1,43 @@
+.container {
+ position: relative;
+ z-index: 50;
+ width: 100%;
+ height: 100%;
+ margin: auto;
+
+ &:hover {
+ .settings-icon {
+ opacity: 1;
+ }
+ }
+}
+
+.container .settings-icon {
+ opacity: 0;
+ transition: opacity 0.2s ease-in-out;
+}
+
+.canvas {
+ display: block;
+ width: 100%;
+ height: 100%;
+}
+
+.preset-overlay {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ z-index: 10;
+ padding: var(--theme-spacing-xs) var(--theme-spacing-sm);
+ font-weight: 500;
+ color: var(--theme-colors-foreground);
+ pointer-events: none;
+ background-color: rgb(0 0 0 / 50%);
+ border-radius: 0 var(--theme-radius-md) 0 0;
+ opacity: 0;
+ transition: opacity 0.2s ease-in-out;
+}
+
+.container:hover .preset-overlay {
+ opacity: 1;
+}
diff --git a/src/renderer/features/visualizer/components/butternchurn/visualizer.tsx b/src/renderer/features/visualizer/components/butternchurn/visualizer.tsx
new file mode 100644
index 000000000..e78e49439
--- /dev/null
+++ b/src/renderer/features/visualizer/components/butternchurn/visualizer.tsx
@@ -0,0 +1,316 @@
+import butterchurn from 'butterchurn';
+import butterchurnPresets from 'butterchurn-presets';
+import { createRef, useEffect, useRef, useState } from 'react';
+
+import styles from './visualizer.module.css';
+
+import { useWebAudio } from '/@/renderer/features/player/hooks/use-webaudio';
+import { openVisualizerSettingsModal } from '/@/renderer/features/player/utils/open-visualizer-settings-modal';
+import { ComponentErrorBoundary } from '/@/renderer/features/shared/components/component-error-boundary';
+import { useSettingsStore, useSettingsStoreActions } from '/@/renderer/store';
+import { usePlayerStatus } from '/@/renderer/store/player.store';
+import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
+import { Text } from '/@/shared/components/text/text';
+import { PlayerStatus } from '/@/shared/types/types';
+
+type ButterchurnVisualizer = {
+ connectAudio: (audioNode: AudioNode) => void;
+ loadPreset: (preset: any, blendTime: number) => void;
+ render: () => void;
+ setRendererSize: (width: number, height: number) => void;
+};
+
+const VisualizerInner = () => {
+ const { webAudio } = useWebAudio();
+ const canvasRef = createRef();
+ const containerRef = createRef();
+ const [visualizer, setVisualizer] = useState();
+ const animationFrameRef = useRef(undefined);
+ const resizeObserverRef = useRef(undefined);
+ const cycleTimerRef = useRef(undefined);
+ const cycleStartTimeRef = useRef(undefined);
+ const butterchurnSettings = useSettingsStore((store) => store.visualizer.butterchurn);
+ const { setSettings } = useSettingsStoreActions();
+ const playerStatus = usePlayerStatus();
+ const isPlaying = playerStatus === PlayerStatus.PLAYING;
+
+ useEffect(() => {
+ const { context, gains } = webAudio || {};
+ if (
+ context &&
+ gains &&
+ canvasRef.current &&
+ containerRef.current &&
+ !visualizer &&
+ isPlaying
+ ) {
+ const canvas = canvasRef.current;
+ const container = containerRef.current;
+
+ const getDimensions = () => {
+ const rect = container.getBoundingClientRect();
+ return {
+ height: rect.height || 600,
+ width: rect.width || 800,
+ };
+ };
+
+ let dimensions = getDimensions();
+
+ // If dimensions are 0, wait for next frame
+ if (dimensions.width === 0 || dimensions.height === 0) {
+ requestAnimationFrame(() => {
+ dimensions = getDimensions();
+ if (dimensions.width > 0 && dimensions.height > 0) {
+ initializeVisualizer(dimensions.width, dimensions.height);
+ }
+ });
+ } else {
+ initializeVisualizer(dimensions.width, dimensions.height);
+ }
+
+ function initializeVisualizer(width: number, height: number) {
+ if (!gains || gains.length === 0) return;
+
+ canvas.width = width;
+ canvas.height = height;
+
+ try {
+ const butterchurnInstance = butterchurn.createVisualizer(context, canvas, {
+ height,
+ width,
+ }) as ButterchurnVisualizer;
+
+ // Connect to audio gains (use the first gain node)
+ butterchurnInstance.connectAudio(gains[0]);
+
+ // Load preset from settings or default
+ const presets = butterchurnPresets.getPresets();
+ const presetNames = Object.keys(presets);
+
+ if (presetNames.length > 0) {
+ const presetName =
+ butterchurnSettings.currentPreset &&
+ presets[butterchurnSettings.currentPreset]
+ ? butterchurnSettings.currentPreset
+ : presetNames[0];
+ const preset = presets[presetName];
+ butterchurnInstance.loadPreset(
+ preset,
+ butterchurnSettings.blendTime || 0.0,
+ );
+ // Initialize cycle timer
+ cycleStartTimeRef.current = Date.now();
+ }
+
+ setVisualizer(butterchurnInstance);
+ } catch (error) {
+ console.error('Failed to create butterchurn visualizer:', error);
+ }
+ }
+ }
+
+ return () => {};
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [webAudio, canvasRef, containerRef, visualizer, isPlaying]);
+
+ // Handle resize
+ useEffect(() => {
+ const container = containerRef.current;
+ if (!container || !visualizer) return;
+
+ const handleResize = () => {
+ const rect = container.getBoundingClientRect();
+ const width = rect.width;
+ const height = rect.height;
+
+ if (canvasRef.current) {
+ canvasRef.current.width = width;
+ canvasRef.current.height = height;
+ }
+
+ visualizer.setRendererSize(width, height);
+ };
+
+ resizeObserverRef.current = new ResizeObserver(handleResize);
+ resizeObserverRef.current.observe(container);
+
+ window.addEventListener('resize', handleResize);
+
+ return () => {
+ window.removeEventListener('resize', handleResize);
+ };
+ }, [visualizer, containerRef, canvasRef]);
+
+ // Update preset when currentPreset or blendTime changes (but not when cycling)
+ const isCyclingRef = useRef(false);
+
+ useEffect(() => {
+ if (!visualizer || !butterchurnSettings.currentPreset) return;
+
+ // Skip if we're currently cycling (to avoid recreating the visualizer)
+ if (isCyclingRef.current) {
+ isCyclingRef.current = false;
+ return;
+ }
+
+ const presets = butterchurnPresets.getPresets();
+ const preset = presets[butterchurnSettings.currentPreset];
+
+ if (preset) {
+ visualizer.loadPreset(preset, butterchurnSettings.blendTime || 0.0);
+ // Reset cycle timer when preset changes manually
+ cycleStartTimeRef.current = Date.now();
+ }
+ }, [visualizer, butterchurnSettings.currentPreset, butterchurnSettings.blendTime]);
+
+ // Handle preset cycling
+ useEffect(() => {
+ if (!visualizer || !butterchurnSettings.cyclePresets) {
+ // Clear cycle timer if cycling is disabled
+ if (cycleTimerRef.current) {
+ clearInterval(cycleTimerRef.current);
+ cycleTimerRef.current = undefined;
+ }
+ return;
+ }
+
+ const presets = butterchurnPresets.getPresets();
+ const allPresetNames = Object.keys(presets);
+
+ // Get the list of presets to cycle through
+ const presetList = butterchurnSettings.includeAllPresets
+ ? allPresetNames
+ : butterchurnSettings.selectedPresets.length > 0
+ ? butterchurnSettings.selectedPresets.filter((name) => presets[name])
+ : allPresetNames;
+
+ if (presetList.length === 0) return;
+
+ // Reset cycle timer when settings change
+ cycleStartTimeRef.current = Date.now();
+
+ const cycleToNextPreset = () => {
+ if (!visualizer) return;
+
+ const currentPresetName = butterchurnSettings.currentPreset;
+ let nextPresetName: string;
+
+ if (butterchurnSettings.randomizeNextPreset) {
+ // Randomly select a preset (excluding current if there are multiple)
+ const availablePresets =
+ presetList.length > 1
+ ? presetList.filter((name) => name !== currentPresetName)
+ : presetList;
+ const randomIndex = Math.floor(Math.random() * availablePresets.length);
+ nextPresetName = availablePresets[randomIndex];
+ } else {
+ // Cycle to next preset in order
+ const currentIndex = currentPresetName ? presetList.indexOf(currentPresetName) : -1;
+ const nextIndex =
+ currentIndex >= 0 && currentIndex < presetList.length - 1
+ ? currentIndex + 1
+ : 0;
+ nextPresetName = presetList[nextIndex];
+ }
+
+ const nextPreset = presets[nextPresetName];
+ if (nextPreset) {
+ // Get current settings to ensure we use the latest blendTime
+ const currentSettings = useSettingsStore.getState().visualizer.butterchurn;
+
+ // Mark that we're cycling to prevent the preset change effect from running
+ isCyclingRef.current = true;
+
+ // Load the preset with blending
+ visualizer.loadPreset(nextPreset, currentSettings.blendTime || 0.0);
+
+ // Update currentPreset in settings
+ const currentVisualizer = useSettingsStore.getState().visualizer;
+ setSettings({
+ visualizer: {
+ ...currentVisualizer,
+ butterchurn: {
+ ...currentVisualizer.butterchurn,
+ currentPreset: nextPresetName,
+ },
+ },
+ });
+
+ cycleStartTimeRef.current = Date.now();
+ }
+ };
+
+ // Check every second if it's time to cycle
+ cycleTimerRef.current = setInterval(() => {
+ if (cycleStartTimeRef.current === undefined) {
+ cycleStartTimeRef.current = Date.now();
+ return;
+ }
+ const elapsed = (Date.now() - cycleStartTimeRef.current) / 1000; // Convert to seconds
+ if (elapsed >= butterchurnSettings.cycleTime) {
+ cycleToNextPreset();
+ }
+ }, 1000);
+
+ return () => {
+ if (cycleTimerRef.current) {
+ clearInterval(cycleTimerRef.current);
+ cycleTimerRef.current = undefined;
+ }
+ };
+ }, [visualizer, butterchurnSettings, setSettings]);
+
+ useEffect(() => {
+ if (!visualizer) return;
+
+ let lastFrameTime = 0;
+ const maxFPS = butterchurnSettings.maxFPS;
+ const minFrameInterval = maxFPS > 0 ? 1000 / maxFPS : 0;
+
+ const render = (currentTime: number) => {
+ if (maxFPS === 0 || currentTime - lastFrameTime >= minFrameInterval) {
+ visualizer.render();
+ lastFrameTime = currentTime;
+ }
+ animationFrameRef.current = requestAnimationFrame(render);
+ };
+
+ animationFrameRef.current = requestAnimationFrame(render);
+
+ return () => {
+ if (animationFrameRef.current) {
+ cancelAnimationFrame(animationFrameRef.current);
+ }
+ };
+ }, [visualizer, butterchurnSettings.maxFPS]);
+
+ return (
+
+
+
+ {butterchurnSettings.currentPreset && (
+
+ {butterchurnSettings.currentPreset}
+
+ )}
+
+ );
+};
+
+export const Visualizer = () => {
+ return (
+
+
+
+ );
+};
diff --git a/src/renderer/router/app-router.tsx b/src/renderer/router/app-router.tsx
index 756cd8a97..ae1529b3b 100644
--- a/src/renderer/router/app-router.tsx
+++ b/src/renderer/router/app-router.tsx
@@ -8,6 +8,7 @@ import { UpdatePlaylistContextModal } from '/@/renderer/features/playlists/compo
import { SettingsContextModal } from '/@/renderer/features/settings/components/settings-modal';
import { RouterErrorBoundary } from '/@/renderer/features/shared/components/router-error-boundary';
import { ShareItemContextModal } from '/@/renderer/features/sharing/components/share-item-context-modal';
+import { VisualizerSettingsContextModal } from '/@/renderer/features/visualizer/components/audiomotionanalyzer/visualizer-settings-modal';
import { AuthenticationOutlet } from '/@/renderer/layouts/authentication-outlet';
import { ResponsiveLayout } from '/@/renderer/layouts/responsive-layout';
import { AppOutlet } from '/@/renderer/router/app-outlet';
@@ -97,6 +98,7 @@ export const AppRouter = () => {
shareItem: ShareItemContextModal,
shuffleAll: ShuffleAllContextModal,
updatePlaylist: UpdatePlaylistContextModal,
+ visualizerSettings: VisualizerSettingsContextModal,
}}
>
diff --git a/src/renderer/store/settings.store.ts b/src/renderer/store/settings.store.ts
index 0230ba392..5a42ab500 100644
--- a/src/renderer/store/settings.store.ts
+++ b/src/renderer/store/settings.store.ts
@@ -216,6 +216,120 @@ const PlayerbarSliderSchema = z.object({
type: PlayerbarSliderTypeSchema,
});
+const AudioMotionAnalyzerSettingsSchema = z.object({
+ alphaBars: z
+ .boolean()
+ .describe(
+ 'When set to true each bar’s amplitude affects its opacity, i.e., higher bars are rendered more opaque while shorter bars are more transparent. This is similar to the lumiBars effect, but bars’ amplitudes are preserved and it also works on Discrete mode and radial spectrum.',
+ ),
+ ansiBands: z
+ .boolean()
+ .describe(
+ 'When set to true, ANSI/IEC preferred frequencies are used to generate the bands for octave bands modes (see mode). The preferred base-10 scale is used to compute the center and bandedge frequencies, as specified in the ANSI S1.11-2004 standard. When false, bands are based on the equal-tempered scale, so that in 1/12 octave bands the center of each band is perfectly tuned to a musical note.',
+ ),
+ barSpace: z
+ .number()
+ .describe(
+ 'Customize the spacing between bars in frequency bands modes (see mode). Use a value between 0 and 1 for spacing proportional to the band width. Values >= 1 will be considered as a literal number of pixels.',
+ ),
+ channelLayout: z
+ .enum(['single', 'dual-combined', 'dual-horizontal', 'dual-vertical'])
+ .describe('Defines the number and layout of analyzer channels.'),
+ colorMode: z
+ .enum(['gradient', 'bar-index', 'bar-level'])
+ .describe('Selects the desired mode for coloring the analyzer bars.'),
+ customGradients: z.array(
+ z.object({
+ colorStops: z.array(
+ z.string().or(
+ z.object({
+ color: z.string(),
+ level: z.number().min(0).max(1).optional(),
+ pos: z.number().min(0).max(1).optional(),
+ }),
+ ),
+ ),
+ dir: z.string().optional(),
+ name: z.string(),
+ }),
+ ),
+ fadePeaks: z
+ .boolean()
+ .describe(
+ 'When true, peaks fade out instead of falling down. It has no effect when peakLine is active.',
+ ),
+ fftSize: z
+ .number()
+ .describe(
+ 'Number of samples used for the FFT performed by the AnalyzerNode. It must be a power of 2 between 32 and 32768, so valid values are: 32, 64, 128, 256, 512, 1024, 2048, 4096, 8192, 16384, and 32768. Higher values provide more detail in the frequency domain, but less detail in the time domain (slower response), so you may need to adjust smoothing accordingly.',
+ ),
+ fillAlpha: z.number(),
+ frequencyScale: z.enum(['bark', 'linear', 'log', 'mel']),
+ gradient: z.string(),
+ gradientLeft: z.string().optional(),
+ gradientRight: z.string().optional(),
+ gravity: z.number(),
+ ledBars: z.boolean(),
+ linearAmplitude: z.boolean(),
+ linearBoost: z.number(),
+ lineWidth: z.number(),
+ loRes: z.boolean(),
+ lumiBars: z.boolean(),
+ maxDecibels: z.number(),
+ maxFPS: z.number(),
+ maxFreq: z.number(),
+ minDecibels: z.number(),
+ minFreq: z.number(),
+ mirror: z.number(),
+ mode: z.number(),
+ noteLabels: z.boolean(),
+ outlineBars: z.boolean(),
+ peakFadeTime: z.number(),
+ peakHoldTime: z.number(),
+ peakLine: z.boolean(),
+ presets: z.array(
+ z.object({
+ name: z.string(),
+ value: z.any(),
+ }),
+ ),
+ radial: z.boolean(),
+ radialInvert: z.boolean(),
+ radius: z.number(),
+ reflexAlpha: z.number(),
+ reflexBright: z.number(),
+ reflexFit: z.boolean(),
+ reflexRatio: z.number(),
+ roundBars: z.boolean(),
+ showFPS: z.boolean(),
+ showPeaks: z.boolean(),
+ showScaleX: z.boolean(),
+ showScaleY: z.boolean(),
+ smoothing: z.number(),
+ spinSpeed: z.number(),
+ splitGradient: z.boolean(),
+ trueLeds: z.boolean(),
+ volume: z.number(),
+ weightingFilter: z.enum(['', 'A', 'B', 'C', 'D', 'Z']),
+});
+
+const ButterchurnSettingsSchema = z.object({
+ blendTime: z.number().min(0).max(10),
+ currentPreset: z.string().optional(),
+ cyclePresets: z.boolean(),
+ cycleTime: z.number().min(1).max(300),
+ includeAllPresets: z.boolean(),
+ maxFPS: z.number().min(0),
+ randomizeNextPreset: z.boolean(),
+ selectedPresets: z.array(z.string()),
+});
+
+const VisualizerSettingsSchema = z.object({
+ audiomotionanalyzer: AudioMotionAnalyzerSettingsSchema,
+ butterchurn: ButterchurnSettingsSchema,
+ type: z.enum(['audiomotionanalyzer', 'butterchurn']),
+});
+
export const GeneralSettingsSchema = z.object({
accent: z
.string()
@@ -440,6 +554,7 @@ export const ValidationSettingsStateSchema = z.object({
z.literal('window'),
z.string(),
]),
+ visualizer: VisualizerSettingsSchema,
window: WindowSettingsSchema,
});
@@ -1294,6 +1409,70 @@ const initialState: SettingsState = {
username: 'feishin',
},
tab: 'general',
+ visualizer: {
+ audiomotionanalyzer: {
+ alphaBars: false,
+ ansiBands: false,
+ barSpace: 0.1,
+ channelLayout: 'single',
+ colorMode: 'gradient',
+ customGradients: [],
+ fadePeaks: false,
+ fftSize: 8192,
+ fillAlpha: 1,
+ frequencyScale: 'log',
+ gradient: 'prism',
+ gravity: 3.8,
+ ledBars: true,
+ linearAmplitude: true,
+ linearBoost: 4,
+ lineWidth: 0,
+ loRes: false,
+ lumiBars: false,
+ maxDecibels: -25,
+ maxFPS: 0,
+ maxFreq: 22000,
+ minDecibels: -85,
+ minFreq: 20,
+ mirror: 0,
+ mode: 8,
+ noteLabels: false,
+ outlineBars: false,
+ peakFadeTime: 750,
+ peakHoldTime: 500,
+ peakLine: false,
+ presets: [],
+ radial: false,
+ radialInvert: false,
+ radius: 0.6,
+ reflexAlpha: 0.5,
+ reflexBright: 1,
+ reflexFit: false,
+ reflexRatio: 0,
+ roundBars: false,
+ showFPS: false,
+ showPeaks: false,
+ showScaleX: false,
+ showScaleY: false,
+ smoothing: 0.5,
+ spinSpeed: 0.5,
+ splitGradient: false,
+ trueLeds: false,
+ volume: 1,
+ weightingFilter: '',
+ },
+ butterchurn: {
+ blendTime: 2.5,
+ currentPreset: undefined,
+ cyclePresets: true,
+ cycleTime: 30,
+ includeAllPresets: true,
+ maxFPS: 0,
+ randomizeNextPreset: true,
+ selectedPresets: [],
+ },
+ type: 'audiomotionanalyzer',
+ },
window: {
disableAutoUpdate: false,
exitToTray: false,
@@ -1364,6 +1543,7 @@ export const useSettingsStore = createWithEqualityFn()(
state.queryBuilder = resetState.queryBuilder;
state.remote = resetState.remote;
state.tab = resetState.tab;
+ state.visualizer = resetState.visualizer;
state.window = resetState.window;
});
},
@@ -1633,3 +1813,5 @@ export const usePlayerbarSlider = () => useSettingsStore((store) => store.genera
export const useGenreTarget = () => useSettingsStore((store) => store.general.genreTarget);
export const useAutoDJSettings = () => useSettingsStore((store) => store.autoDJ, shallow);
+
+export const useVisualizerSettings = () => useSettingsStore((store) => store.visualizer, shallow);
diff --git a/src/shared/components/angle-slider/angle-slider.tsx b/src/shared/components/angle-slider/angle-slider.tsx
new file mode 100644
index 000000000..ba1a7e8e0
--- /dev/null
+++ b/src/shared/components/angle-slider/angle-slider.tsx
@@ -0,0 +1,13 @@
+import {
+ AngleSlider as MantineAngleSlider,
+ AngleSliderProps as MantineAngleSliderProps,
+} from '@mantine/core';
+import { forwardRef } from 'react';
+
+export interface AngleSliderProps extends MantineAngleSliderProps {}
+
+export const AngleSlider = forwardRef((props, ref) => {
+ return ;
+});
+
+AngleSlider.displayName = 'AngleSlider';
diff --git a/src/shared/components/fieldset/fieldset.module.css b/src/shared/components/fieldset/fieldset.module.css
new file mode 100644
index 000000000..9d5006fd1
--- /dev/null
+++ b/src/shared/components/fieldset/fieldset.module.css
@@ -0,0 +1,5 @@
+.root {
+ &[data-variant='default'] {
+ background: none;
+ }
+}
diff --git a/src/shared/components/fieldset/fieldset.tsx b/src/shared/components/fieldset/fieldset.tsx
new file mode 100644
index 000000000..5743fe27b
--- /dev/null
+++ b/src/shared/components/fieldset/fieldset.tsx
@@ -0,0 +1,21 @@
+import { Fieldset as MantineFieldset, FieldsetProps as MantineFieldsetProps } from '@mantine/core';
+import { CSSProperties, forwardRef } from 'react';
+
+import styles from './fieldset.module.css';
+
+export interface FieldsetProps extends MantineFieldsetProps {
+ maxWidth?: CSSProperties['maxWidth'];
+ width?: CSSProperties['width'];
+}
+
+export const Fieldset = forwardRef(
+ ({ children, ...props }, ref) => {
+ return (
+
+ {children}
+
+ );
+ },
+);
+
+Fieldset.displayName = 'Fieldset';
diff --git a/src/shared/components/slider/slider.tsx b/src/shared/components/slider/slider.tsx
index 4a92265a1..04d51263a 100644
--- a/src/shared/components/slider/slider.tsx
+++ b/src/shared/components/slider/slider.tsx
@@ -1,25 +1,29 @@
import type { SliderProps as MantineSliderProps } from '@mantine/core';
import { Slider as MantineSlider } from '@mantine/core';
+import { forwardRef } from 'react';
import styles from './slider.module.css';
export interface SliderProps extends MantineSliderProps {}
-export const Slider = ({ classNames, style, ...props }: SliderProps) => {
- return (
-
- );
-};
+export const Slider = forwardRef(
+ ({ classNames, style, ...props }, ref) => {
+ return (
+
+ );
+ },
+);