Files
feishin/src/renderer/features/visualizer/components/audiomotionanalyzer/visualizer-settings-form.tsx
T
jaxi e402bd5424 fix: f11 fullscreen was completely broken on linux/windows; feat: butterchurn preset expansion + randomized preset arrows (#2280)
* added a ton of visualizers

* added f11 toggle functionality, f11 makes visualizer cover entire screen when pressed while visualizer is maximized

Bundling the additional butterchurn preset packs (extra, md1) pushed
renderer build memory past the default ~2GB Node heap on the macOS CI
runner, causing OOM crashes during the build.

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 20:49:24 -07:00

2219 lines
87 KiB
TypeScript

import { nanoid } from 'nanoid';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import styles from './visualizer-settings-form.module.css';
import i18n from '/@/i18n/i18n';
import {
getButterchurnPresetOptions,
loadAllButterchurnPresets,
} from '/@/renderer/features/visualizer/components/butternchurn/visualizer';
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';
type ButterchurnPresetOption = { label: string; value: string };
let butterchurnPresetOptionsCache: ButterchurnPresetOption[] | null = null;
const loadButterchurnPresetOptions = async (): Promise<ButterchurnPresetOption[]> => {
if (butterchurnPresetOptionsCache) return butterchurnPresetOptionsCache;
const mergedPresets = await loadAllButterchurnPresets();
const presets = getButterchurnPresetOptions(mergedPresets);
const presetNames = Object.keys(presets);
butterchurnPresetOptionsCache = presetNames.map((presetName) => ({
label: presetName,
value: presetName,
}));
return butterchurnPresetOptionsCache;
};
const useButterchurnPresetOptions = () => {
const [options, setOptions] = useState<ButterchurnPresetOption[]>(
butterchurnPresetOptionsCache ?? [],
);
useEffect(() => {
if (butterchurnPresetOptionsCache) return;
void loadButterchurnPresetOptions().then(setOptions);
}, []);
return options;
};
const modeOptions: { label: string; value: string }[] = [
{ label: i18n.t('visualizer.options.mode.0') as string, value: '0' },
{ label: i18n.t('visualizer.options.mode.1') as string, value: '1' },
{ label: i18n.t('visualizer.options.mode.2') as string, value: '2' },
{ label: i18n.t('visualizer.options.mode.3') as string, value: '3' },
{ label: i18n.t('visualizer.options.mode.4') as string, value: '4' },
{ label: i18n.t('visualizer.options.mode.5') as string, value: '5' },
{ label: i18n.t('visualizer.options.mode.6') as string, value: '6' },
{ label: i18n.t('visualizer.options.mode.7') as string, value: '7' },
{ label: i18n.t('visualizer.options.mode.8') as string, value: '8' },
{ label: i18n.t('visualizer.options.mode.10') as string, value: '10' },
];
const colorModeOptions: { label: string; value: string }[] = [
{ label: i18n.t('visualizer.options.colorMode.gradient') as string, value: 'gradient' },
{ label: i18n.t('visualizer.options.colorMode.barIndex') as string, value: 'bar-index' },
{ label: i18n.t('visualizer.options.colorMode.barLevel') as string, value: 'bar-level' },
];
const gradientOptions: { label: string; value: string }[] = [
{ label: i18n.t('visualizer.options.gradient.classic') as string, value: 'classic' },
{ label: i18n.t('visualizer.options.gradient.prism') as string, value: 'prism' },
{ label: i18n.t('visualizer.options.gradient.rainbow') as string, value: 'rainbow' },
{ label: i18n.t('visualizer.options.gradient.steelblue') as string, value: 'steelblue' },
{ label: i18n.t('visualizer.options.gradient.orangered') as string, value: 'orangered' },
];
const channelLayoutOptions: { label: string; value: string }[] = [
{ label: i18n.t('visualizer.options.channelLayout.single') as string, value: 'single' },
{
label: i18n.t('visualizer.options.channelLayout.dualCombined') as string,
value: 'dual-combined',
},
{
label: i18n.t('visualizer.options.channelLayout.dualHorizontal') as string,
value: 'dual-horizontal',
},
{
label: i18n.t('visualizer.options.channelLayout.dualVertical') as string,
value: 'dual-vertical',
},
];
const fftSizeOptions: { label: string; value: 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: string }[] = [
{ label: i18n.t('visualizer.options.frequencyScale.bark') as string, value: 'bark' },
{ label: i18n.t('visualizer.options.frequencyScale.linear') as string, value: 'linear' },
{ label: i18n.t('visualizer.options.frequencyScale.log') as string, value: 'log' },
{ label: i18n.t('visualizer.options.frequencyScale.mel') as string, value: 'mel' },
];
const weightingFilterOptions = [
{ label: i18n.t('visualizer.options.weightingFilter.none') as string, value: '' },
{ label: i18n.t('visualizer.options.weightingFilter.a') as string, value: 'A' },
{ label: i18n.t('visualizer.options.weightingFilter.b') as string, value: 'B' },
{ label: i18n.t('visualizer.options.weightingFilter.C') as string, value: 'C' },
{ label: i18n.t('visualizer.options.weightingFilter.D') as string, value: 'D' },
{ label: i18n.t('visualizer.options.weightingFilter.z') as string, 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 = <K extends keyof typeof visualizer.audiomotionanalyzer>(
property: K,
value: (typeof visualizer.audiomotionanalyzer)[K],
) => {
setSettings({
visualizer: {
audiomotionanalyzer: {
[property]: value,
},
},
});
};
return { updateProperty, visualizer };
};
const useUpdateButterchurn = () => {
const visualizer = useVisualizerSettings();
const { setSettings } = useSettingsStoreActions();
const updateProperty = <K extends keyof typeof visualizer.butterchurn>(
property: K,
value: (typeof visualizer.butterchurn)[K],
) => {
setSettings({
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: {
type: value as 'audiomotionanalyzer' | 'butterchurn',
},
});
};
return (
<div className={styles.container}>
<Fieldset legend={t('visualizer.visualizerType')}>
<Stack>
<SegmentedControl
data={visualizerTypeOptions}
onChange={handleTypeChange}
value={visualizer.type}
/>
</Stack>
</Fieldset>
{visualizer.type === 'audiomotionanalyzer' && (
<>
<PresetSettings />
<GeneralSettings />
<ColorSettings />
<FFTSettings />
<FrequencySettings />
<SensitivitySettings />
<LinearAmplitudeSettings />
<PeakBehaviorSettings />
<RadialSpectrumSettings />
<ReflexMirrorSettings />
<ToggleSettings />
</>
)}
{visualizer.type === 'butterchurn' && (
<>
<ButterchurnGeneralSettings />
<ButterChurnCycleSettings />
</>
)}
</div>
);
};
const VisualizerSelect = (props: SelectProps) => {
return (
<Select
searchable
styles={{ label: { display: 'flex', justifyContent: 'center' } }}
{...props}
/>
);
};
const VisualizerSlider = (props: SliderProps & { label?: React.ReactNode }) => {
const { defaultValue, label, max, min, onChange, onChangeEnd, step, ...rest } = props;
const sliderRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [value, setValue] = useState<number>((defaultValue as number) ?? 0);
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState<number>((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<HTMLInputElement>) => {
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 (
<Stack gap="sm">
{label && (
<div style={{ display: 'flex', justifyContent: 'center' }}>
{typeof label === 'string' ? (
<Text fw="500" size="sm" ta="center">
{label}
</Text>
) : (
label
)}
</div>
)}
<Slider
label={null}
max={max}
min={min}
onChange={handleChange}
onChangeEnd={onChangeEnd}
ref={sliderRef}
step={step}
styles={{
root: { alignSelf: 'center', display: 'flex' },
}}
value={value}
w="100px"
{...rest}
/>
{isEditing ? (
<NumberInput
max={max}
min={min}
onBlur={handleInputBlur}
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
ref={inputRef}
size="xs"
step={step}
style={{ alignSelf: 'center', width: '80px' }}
styles={{ input: { textAlign: 'center' } }}
value={editValue}
/>
) : (
<Text
fw="500"
onClick={handleTextClick}
size="sm"
style={{ cursor: 'pointer', userSelect: 'none' }}
ta="center"
>
{value.toFixed(step && step < 1 ? 1 : 0)}
</Text>
)}
</Stack>
);
};
const VisualizerToggle = (props: {
disabled?: boolean;
label: string;
onChange: (value: boolean) => void;
value: boolean;
}) => {
const { disabled, label, onChange, value } = props;
return (
<Button
disabled={disabled}
onClick={() => onChange(!value)}
variant={value ? 'filled' : 'default'}
>
{label}
</Button>
);
};
const PresetSettings = () => {
const { t } = useTranslation();
const visualizer = useVisualizerSettings();
const { setSettings } = useSettingsStoreActions();
const [selectedPreset, setSelectedPreset] = useState<null | string>(null);
const [isSaving, setIsSaving] = useState(false);
const [isRenaming, setIsRenaming] = useState(false);
const [newPresetName, setNewPresetName] = useState('');
const [isPasting, setIsPasting] = useState(false);
const [pasteValue, setPasteValue] = useState('');
const applyPreset = (presetId: null | string) => {
if (!presetId) return;
const preset = visualizer.audiomotionanalyzer.presets.find((p) => p.id === presetId);
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,
opacity: 1,
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: {
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.id === existingPreset.id
? {
...p,
value: getCurrentSettingsAsPresetValue(),
}
: p,
);
setSettings({
visualizer: {
audiomotionanalyzer: {
presets: updatedPresets,
},
},
});
setSelectedPreset(existingPreset.id);
} else {
// Add new preset
const newPreset = {
id: nanoid(),
name: newPresetName.trim(),
value: getCurrentSettingsAsPresetValue(),
};
setSettings({
visualizer: {
audiomotionanalyzer: {
presets: [...visualizer.audiomotionanalyzer.presets, newPreset],
},
},
});
setSelectedPreset(newPreset.id);
}
setNewPresetName('');
setIsSaving(false);
};
const getCurrentSettingsAsPresetValue = () => {
return {
alphaBars: visualizer.audiomotionanalyzer.alphaBars,
ansiBands: visualizer.audiomotionanalyzer.ansiBands,
barSpace: visualizer.audiomotionanalyzer.barSpace,
channelLayout: visualizer.audiomotionanalyzer.channelLayout,
colorMode: visualizer.audiomotionanalyzer.colorMode,
customGradients: visualizer.audiomotionanalyzer.customGradients,
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,
opacity: visualizer.audiomotionanalyzer.opacity,
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 || !newPresetName.trim()) return;
const selectedPresetObj = visualizer.audiomotionanalyzer.presets.find(
(p) => p.id === selectedPreset,
);
if (!selectedPresetObj) return;
let trimmedName = newPresetName.trim();
const isRenaming = trimmedName !== selectedPresetObj.name;
if (isRenaming) {
const existingNames = visualizer.audiomotionanalyzer.presets
.filter((p) => p.id !== selectedPreset)
.map((p) => p.name);
if (existingNames.includes(trimmedName)) {
const pattern = /^(.+?)(\s+\((\d+)\))?$/;
const match = trimmedName.match(pattern);
const baseName = match ? match[1] : trimmedName;
let counter = 1;
while (existingNames.includes(`${baseName} (${counter})`)) {
counter++;
}
trimmedName = `${baseName} (${counter})`;
}
}
const updatedPresets = visualizer.audiomotionanalyzer.presets.map((p) =>
p.id === selectedPreset
? {
...p,
name: trimmedName,
value: getCurrentSettingsAsPresetValue(),
}
: p,
);
setSettings({
visualizer: {
...visualizer,
audiomotionanalyzer: {
...visualizer.audiomotionanalyzer,
presets: updatedPresets,
},
},
});
setNewPresetName('');
setIsRenaming(false);
};
const handleDeletePreset = () => {
if (!selectedPreset) return;
const updatedPresets = visualizer.audiomotionanalyzer.presets.filter(
(p) => p.id !== selectedPreset,
);
setSettings({
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'),
});
} catch {
toast.error({
message: t('visualizer.configCopyFailed'),
});
}
};
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,
opacity: 1,
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 pastedCustomGradients = Array.isArray(parsed.customGradients)
? parsed.customGradients
: [];
const parsedWithoutCustomGradients = { ...parsed };
delete parsedWithoutCustomGradients.customGradients;
// Determine the channel layout from the pasted config (or use default)
const pastedChannelLayout = parsed.channelLayout || initialDefaults.channelLayout;
// Get the gradient values that would be used based on channel layout
const gradientNamesToCheck: (string | undefined)[] = [];
if (pastedChannelLayout === 'single') {
gradientNamesToCheck.push(parsed.gradient);
} else {
gradientNamesToCheck.push(parsed.gradientLeft, parsed.gradientRight);
}
// Check if any of the gradient names match custom gradients in the pasted config
const pastedCustomGradientNames = pastedCustomGradients.map((g) => g.name);
const isUsingCustomGradient = gradientNamesToCheck.some(
(gradientName) => gradientName && pastedCustomGradientNames.includes(gradientName),
);
// Only append custom gradients if they're actually being used in the configuration
const customGradientsToUse = isUsingCustomGradient
? [
...(visualizer.audiomotionanalyzer.customGradients || []),
...pastedCustomGradients,
]
: pastedCustomGradients;
const configValue = {
...initialDefaults,
...parsedWithoutCustomGradients,
customGradients: customGradientsToUse,
};
setSettings({
visualizer: {
audiomotionanalyzer: {
...configValue,
},
},
});
toast.success({
message: t('visualizer.configPasted'),
});
setPasteValue('');
setIsPasting(false);
} catch {
toast.error({
message: t('visualizer.configPasteFailed'),
});
}
};
const handlePasteFromClipboard = async () => {
try {
const text = await navigator.clipboard.readText();
setPasteValue(text);
setIsPasting(true);
} catch {
toast.error({
message: t('visualizer.configPasteReadFailed'),
});
}
};
const presetOptions = useMemo(() => {
return visualizer.audiomotionanalyzer.presets.map((preset) => ({
label: preset.name,
value: preset.id,
}));
}, [visualizer.audiomotionanalyzer.presets]);
return (
<Fieldset legend={t('visualizer.presets')}>
<Stack>
<VisualizerSelect
data={presetOptions}
label={t('visualizer.selectPreset')}
onChange={handlePresetChange}
value={selectedPreset || undefined}
/>
{isSaving ? (
<Group grow>
<TextInput
autoFocus
label={t('visualizer.presetName')}
onChange={(e) => setNewPresetName(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleSavePreset();
} else if (e.key === 'Escape') {
setIsSaving(false);
setNewPresetName('');
}
}}
placeholder={t('visualizer.presetNamePlaceholder')}
value={newPresetName}
/>
<Group style={{ alignSelf: 'flex-end' }}>
<Button onClick={() => setIsSaving(false)} variant="subtle">
{t('common.cancel')}
</Button>
<Button
disabled={!newPresetName.trim()}
onClick={handleSavePreset}
variant="filled"
>
{t('common.save')}
</Button>
</Group>
</Group>
) : isRenaming ? (
<Group grow>
<TextInput
autoFocus
label={t('visualizer.presetName')}
onChange={(e) => setNewPresetName(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleUpdatePreset();
} else if (e.key === 'Escape') {
setIsRenaming(false);
setNewPresetName('');
}
}}
placeholder={t('visualizer.presetNamePlaceholder')}
value={newPresetName}
/>
<Group style={{ alignSelf: 'flex-end' }}>
<Button onClick={() => setIsRenaming(false)} variant="subtle">
{t('common.cancel')}
</Button>
<Button
disabled={!newPresetName.trim()}
onClick={handleUpdatePreset}
variant="filled"
>
{t('common.save')}
</Button>
</Group>
</Group>
) : isPasting ? (
<Stack>
<Textarea
autosize
label={t('visualizer.pasteConfiguration')}
maxRows={10}
minRows={5}
onChange={(e) => setPasteValue(e.currentTarget.value)}
placeholder={t('visualizer.pasteConfigurationPlaceholder')}
spellCheck={false}
value={pasteValue}
/>
<Group>
<Button onClick={handlePasteFromClipboard} variant="subtle">
{t('visualizer.pasteFromClipboard')}
</Button>
<Button onClick={() => setIsPasting(false)} variant="subtle">
{t('common.cancel')}
</Button>
<Button
disabled={!pasteValue.trim()}
onClick={handlePasteConfiguration}
variant="filled"
>
{t('visualizer.applyConfiguration')}
</Button>
</Group>
</Stack>
) : (
<Group>
<Button onClick={() => setIsSaving(true)} variant="default">
{t('visualizer.saveAsPreset')}
</Button>
{selectedPreset && (
<>
<Button
onClick={() => {
const preset = visualizer.audiomotionanalyzer.presets.find(
(p) => p.id === selectedPreset,
);
if (preset) {
setNewPresetName(preset.name);
setIsRenaming(true);
}
}}
variant="default"
>
{t('visualizer.updatePreset')}
</Button>
<Button onClick={handleDeletePreset} variant="subtle">
{t('common.delete')}
</Button>
</>
)}
<Button onClick={handleCopyConfiguration} variant="default">
{t('visualizer.copyConfiguration')}
</Button>
<Button onClick={() => setIsPasting(true)} variant="default">
{t('visualizer.pasteConfiguration')}
</Button>
</Group>
)}
</Stack>
</Fieldset>
);
};
const GeneralSettings = () => {
const { t } = useTranslation();
const { updateProperty, visualizer } = useUpdateAudioMotionAnalyzer();
const isMode18Disabled = visualizer.audiomotionanalyzer.mode > 8;
const isMode10Disabled = visualizer.audiomotionanalyzer.mode !== 10;
const getChannelLayoutKey = (value: string) => {
const layoutMap: Record<string, string> = {
'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 (
<Fieldset
legend={
<Group gap="xs">
{t('visualizer.general')}
<ActionIcon
component="a"
href="https://audiomotion.dev/#/?id=constructor-specific-options"
icon="externalLink"
iconProps={{ color: 'info' }}
size="xs"
target="_blank"
variant="transparent"
/>
</Group>
}
>
<Stack>
<Group grow>
<VisualizerSelect
data={modeOptions}
defaultValue={visualizer.audiomotionanalyzer.mode.toString()}
label={t('visualizer.mode')}
onChange={(e) => updateProperty('mode', Number(e))}
/>
</Group>
<div
style={{
display: 'flex',
gap: 'var(--theme-spacing-md)',
}}
>
<Fieldset legend={t('visualizer.mode1To8')} style={{ flex: 1, flexGrow: 1 }}>
<Group grow>
<VisualizerSelect
data={barSpaceOptions.map((option) => ({
label: option.label,
value: option.value,
}))}
defaultValue={visualizer.audiomotionanalyzer.barSpace.toString()}
disabled={isMode18Disabled}
label={t('visualizer.barSpace')}
onChange={(e) => updateProperty('barSpace', Number(e))}
/>
</Group>
</Fieldset>
<Fieldset legend={t('visualizer.mode10')} style={{ flex: 1, flexGrow: 1 }}>
<Group grow>
<VisualizerSlider
defaultValue={visualizer.audiomotionanalyzer.lineWidth}
disabled={isMode10Disabled}
label={t('visualizer.lineWidth')}
max={4}
min={0}
onChangeEnd={(e) => updateProperty('lineWidth', e)}
step={0.1}
/>
<VisualizerSlider
defaultValue={visualizer.audiomotionanalyzer.fillAlpha}
disabled={isMode10Disabled}
label={t('visualizer.fillAlpha')}
max={1}
min={0}
onChangeEnd={(e) => updateProperty('fillAlpha', e)}
step={0.1}
/>
</Group>
</Fieldset>
</div>
<Group grow>
<VisualizerSelect
data={translatedChannelLayoutOptions}
defaultValue={visualizer.audiomotionanalyzer.channelLayout}
label={t('visualizer.channelLayout')}
onChange={(e) =>
updateProperty(
'channelLayout',
e as
| 'dual-combined'
| 'dual-horizontal'
| 'dual-vertical'
| 'single',
)
}
/>
<VisualizerSlider
defaultValue={visualizer.audiomotionanalyzer.maxFPS}
label={t('visualizer.maxFPS')}
max={144}
min={0}
onChangeEnd={(e) => updateProperty('maxFPS', e)}
/>
<VisualizerSlider
defaultValue={visualizer.audiomotionanalyzer.opacity}
label={t('visualizer.opacity')}
max={1}
min={0}
onChangeEnd={(e) => updateProperty('opacity', e)}
step={0.01}
/>
</Group>
</Stack>
</Fieldset>
);
};
type CustomGradient = {
colorStops: StoredColorStop[];
dir?: string;
name: string;
};
type StoredColorStop = {
color: string;
level?: number;
levelEnabled?: boolean;
pos?: number;
positionEnabled?: boolean;
};
const CustomGradientsManager = () => {
const { t } = useTranslation();
const { updateProperty, visualizer } = useUpdateAudioMotionAnalyzer();
const [isAdding, setIsAdding] = useState(false);
const [editingIndex, setEditingIndex] = useState<null | number>(null);
const [isPasting, setIsPasting] = useState(false);
const [pasteValue, setPasteValue] = useState('');
const [newGradient, setNewGradient] = useState<CustomGradient>({
colorStops: [{ color: '#ff0000', levelEnabled: false, positionEnabled: false }],
dir: 'v',
name: '',
});
const customGradients = visualizer.audiomotionanalyzer.customGradients || [];
const generateDefaultName = () => {
const existingNames = customGradients.map((g) => g.name);
const pattern = /^Custom Gradient (\d+)$/i;
const numbers = existingNames
.map((name) => {
const match = name.match(pattern);
return match ? parseInt(match[1], 10) : null;
})
.filter((num): num is number => num !== null);
if (numbers.length === 0) {
return 'Custom Gradient 1';
}
const maxNumber = Math.max(...numbers);
return `Custom Gradient ${maxNumber + 1}`;
};
const handleStartAdding = () => {
setNewGradient({
colorStops: [{ color: '#ff0000', levelEnabled: false, positionEnabled: false }],
dir: 'v',
name: generateDefaultName(),
});
setIsAdding(true);
};
const handleAddGradient = () => {
if (!newGradient.name.trim()) return;
const updatedGradients = [...customGradients, newGradient];
updateProperty('customGradients', updatedGradients);
setNewGradient({
colorStops: [
{
color: '#ff0000',
level: 0,
levelEnabled: false,
pos: 0,
positionEnabled: false,
},
],
dir: 'v',
name: '',
});
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);
setEditingIndex(index);
setIsAdding(true);
};
const handleSaveEdit = () => {
if (!newGradient.name.trim() || editingIndex === null) return;
const updatedGradients = [...customGradients];
updatedGradients[editingIndex] = newGradient;
updateProperty('customGradients', updatedGradients);
setNewGradient({
colorStops: [{ color: '#ff0000', levelEnabled: false, positionEnabled: false }],
dir: 'v',
name: '',
});
setEditingIndex(null);
setIsAdding(false);
};
const handleCancel = () => {
setNewGradient({
colorStops: [{ color: '#ff0000', levelEnabled: false, positionEnabled: false }],
dir: 'v',
name: '',
});
setEditingIndex(null);
setIsAdding(false);
};
const handleAddColorStop = () => {
setNewGradient({
...newGradient,
colorStops: [
...newGradient.colorStops,
{ color: '#00ff00', levelEnabled: false, positionEnabled: false },
],
});
};
const handleRemoveColorStop = (index: number) => {
if (newGradient.colorStops.length <= 1) return;
setNewGradient({
...newGradient,
colorStops: newGradient.colorStops.filter((_, i) => i !== index),
});
};
const handleColorStopChange = (index: number, color: string) => {
const updatedColorStops = [...newGradient.colorStops];
const currentStop = updatedColorStops[index];
updatedColorStops[index] = {
...currentStop,
color,
};
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;
updatedColorStops[index] = {
...currentStop,
...(currentStop.positionEnabled && posValue !== undefined ? { pos: posValue } : {}),
};
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;
updatedColorStops[index] = {
...currentStop,
...(currentStop.levelEnabled && levelValue !== undefined ? { level: levelValue } : {}),
};
setNewGradient({ ...newGradient, colorStops: updatedColorStops });
};
const handleTogglePos = (index: number, enabled: boolean) => {
const updatedColorStops = [...newGradient.colorStops];
const currentStop = updatedColorStops[index];
updatedColorStops[index] = {
...currentStop,
positionEnabled: enabled,
// Remove pos if disabling
...(enabled && currentStop.pos !== undefined ? { pos: currentStop.pos } : {}),
...(!enabled ? { pos: undefined } : {}),
};
setNewGradient({ ...newGradient, colorStops: updatedColorStops });
};
const handleToggleLevel = (index: number, enabled: boolean) => {
const updatedColorStops = [...newGradient.colorStops];
const currentStop = updatedColorStops[index];
updatedColorStops[index] = {
...currentStop,
levelEnabled: enabled,
// Remove level if disabling
...(enabled && currentStop.level !== undefined ? { level: currentStop.level } : {}),
...(!enabled ? { level: undefined } : {}),
};
setNewGradient({ ...newGradient, colorStops: updatedColorStops });
};
const handleCopyGradient = async (gradient: CustomGradient) => {
try {
const gradientJson = JSON.stringify(gradient, null, 2);
await navigator.clipboard.writeText(gradientJson);
toast.success({
message: t('visualizer.configCopied'),
});
} catch {
toast.error({
message: t('visualizer.configCopyFailed'),
});
}
};
const handlePasteGradient = () => {
if (!pasteValue.trim()) return;
try {
const parsed = JSON.parse(pasteValue.trim());
// Validate that it's a valid gradient object
if (
typeof parsed !== 'object' ||
parsed === null ||
Array.isArray(parsed) ||
!parsed.colorStops ||
!Array.isArray(parsed.colorStops) ||
parsed.colorStops.length === 0
) {
throw new Error('Invalid gradient format');
}
// Generate a unique name if the pasted gradient has a name that already exists
let gradientName = parsed.name || generateDefaultName();
const existingNames = customGradients.map((g) => g.name);
if (existingNames.includes(gradientName)) {
const pattern = /^(.+?)(\s+\((\d+)\))?$/;
const match = gradientName.match(pattern);
const baseName = match ? match[1] : gradientName;
let counter = 1;
while (existingNames.includes(`${baseName} (${counter})`)) {
counter++;
}
gradientName = `${baseName} (${counter})`;
}
const pastedGradient: CustomGradient = {
colorStops: parsed.colorStops.map((stop: any) => ({
color: stop.color || '#ff0000',
level: stop.level,
levelEnabled: stop.levelEnabled || false,
pos: stop.pos,
positionEnabled: stop.positionEnabled || false,
})),
dir: parsed.dir || 'v',
name: gradientName,
};
setNewGradient(pastedGradient);
setPasteValue('');
setIsPasting(false);
setIsAdding(true);
setEditingIndex(null);
} catch {
toast.error({
message: t('visualizer.configPasteFailed'),
});
}
};
return (
<Fieldset
legend={
<Group gap="xs">
{t('visualizer.customGradients')}
<ActionIcon
component="a"
href="https://audiomotion.dev/#/?id=registergradient-name-options-"
icon="externalLink"
iconProps={{ color: 'info' }}
size="xs"
target="_blank"
variant="transparent"
/>
</Group>
}
>
<Stack gap="md">
{customGradients.length > 0 && (
<Stack gap="sm">
{customGradients.map((gradient, index) => (
<Group grow key={index}>
<Group grow>
<Text size="sm">{gradient.name}</Text>
</Group>
<Group justify="flex-end">
<Button
onClick={() => handleCopyGradient(gradient)}
size="xs"
variant="subtle"
>
{t('visualizer.copyConfiguration')}
</Button>
<Button
onClick={() => handleEditGradient(index)}
size="xs"
variant="default"
>
{t('common.edit')}
</Button>
<Button
onClick={() => handleDeleteGradient(index)}
size="xs"
variant="state-error"
>
{t('common.delete')}
</Button>
</Group>
</Group>
))}
</Stack>
)}
{!isAdding && !isPasting ? (
<Group>
<Button onClick={handleStartAdding} size="sm" variant="outline">
{t('visualizer.addCustomGradient')}
</Button>
<Button onClick={() => setIsPasting(true)} size="sm" variant="outline">
{t('visualizer.pasteGradient')}
</Button>
</Group>
) : isPasting ? (
<Stack>
<Textarea
autosize
label={t('visualizer.pasteGradient')}
maxRows={10}
minRows={5}
onChange={(e) => setPasteValue(e.currentTarget.value)}
placeholder={t('visualizer.pasteGradientPlaceholder')}
spellCheck={false}
value={pasteValue}
/>
<Group>
<Button onClick={() => setIsPasting(false)} variant="subtle">
{t('common.cancel')}
</Button>
<Button
disabled={!pasteValue.trim()}
onClick={handlePasteGradient}
variant="filled"
>
{t('common.add')}
</Button>
</Group>
</Stack>
) : (
<>
<Divider />
<Stack gap="sm">
<TextInput
onChange={(e) =>
setNewGradient({ ...newGradient, name: e.currentTarget.value })
}
placeholder={t('visualizer.gradientNamePlaceholder')}
size="sm"
value={newGradient.name}
/>
<SegmentedControl
data={[
{ label: t('visualizer.vertical'), value: 'v' },
{ label: t('visualizer.horizontal'), value: 'h' },
]}
onChange={(value) =>
setNewGradient({
...newGradient,
dir: value,
})
}
size="sm"
value={newGradient.dir || 'v'}
/>
<Stack gap="xl">
<Group justify="space-between">
<Text>{t('visualizer.colorStops')}</Text>
<Button
onClick={handleAddColorStop}
size="xs"
variant="outline"
>
{t('visualizer.addColor')}
</Button>
</Group>
{newGradient.colorStops.map((stop, index) => {
return (
<Group grow key={index}>
<ColorInput
format="hex"
onChangeEnd={(color) =>
handleColorStopChange(index, color)
}
size="sm"
value={stop.color}
/>
<VisualizerSlider
defaultValue={stop.pos}
disabled={!stop.positionEnabled}
label={
<Group
gap="xs"
style={{ alignItems: 'center' }}
>
<Checkbox
checked={stop.positionEnabled || false}
onChange={(e) =>
handleTogglePos(
index,
e.currentTarget.checked,
)
}
size="xs"
/>
<Text fw={500} size="sm">
{t('visualizer.position')}
</Text>
</Group>
}
max={1}
min={0}
onChangeEnd={(e) =>
handleColorStopPosChange(index, e)
}
step={0.1}
/>
<VisualizerSlider
defaultValue={stop.level}
disabled={!stop.levelEnabled}
label={
<Group
gap="xs"
style={{ alignItems: 'center' }}
>
<Checkbox
checked={stop.levelEnabled || false}
onChange={(e) =>
handleToggleLevel(
index,
e.currentTarget.checked,
)
}
size="xs"
/>
<Text fw={500} size="sm">
{t('visualizer.level')}
</Text>
</Group>
}
max={1}
min={0}
onChangeEnd={(e) =>
handleColorStopLevelChange(index, e)
}
step={0.1}
/>
{newGradient.colorStops.length > 1 && (
<Button
onClick={() => handleRemoveColorStop(index)}
size="xs"
variant="subtle"
>
{t('visualizer.remove')}
</Button>
)}
</Group>
);
})}
</Stack>
<Group grow>
<Button onClick={handleCancel} size="sm" variant="subtle">
{t('common.cancel')}
</Button>
<Button
disabled={!newGradient.name.trim()}
onClick={
editingIndex !== null ? handleSaveEdit : handleAddGradient
}
size="sm"
variant="filled"
>
{editingIndex !== null ? t('common.save') : t('common.add')}
</Button>
</Group>
</Stack>
</>
)}
</Stack>
</Fieldset>
);
};
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 allGradientOptions = useMemo(
() => [
{
group: t('visualizer.custom'),
items: (visualizer.audiomotionanalyzer.customGradients || []).map((gradient) => ({
label: gradient.name,
value: gradient.name,
})),
},
{
group: t('visualizer.builtIn'),
items: gradientOptions,
},
],
[t, visualizer.audiomotionanalyzer.customGradients],
);
return (
<Fieldset legend={t('visualizer.colors')}>
<Stack>
<Group grow>
<VisualizerSelect
data={colorModeOptions}
defaultValue={visualizer.audiomotionanalyzer.colorMode}
label={t('visualizer.colorMode')}
onChange={(e) =>
updateProperty(
'colorMode',
(e || 'gradient') as 'bar-index' | 'bar-level' | 'gradient',
)
}
/>
<VisualizerSelect
data={allGradientOptions}
defaultValue={visualizer.audiomotionanalyzer.gradient}
disabled={isGradientDisabled}
label={t('visualizer.gradient')}
onChange={(e) =>
updateProperty(
'gradient',
(e || 'classic') as typeof visualizer.audiomotionanalyzer.gradient,
)
}
/>
</Group>
<Group grow>
<VisualizerSelect
data={allGradientOptions}
defaultValue={visualizer.audiomotionanalyzer.gradientLeft}
disabled={isGradientLeftDisabled}
label={t('visualizer.gradientLeft')}
onChange={(e) =>
updateProperty(
'gradientLeft',
(e ||
'classic') as typeof visualizer.audiomotionanalyzer.gradientLeft,
)
}
/>
<VisualizerSelect
data={allGradientOptions}
defaultValue={visualizer.audiomotionanalyzer.gradientRight}
disabled={isGradientRightDisabled}
label={t('visualizer.gradientRight')}
onChange={(e) =>
updateProperty(
'gradientRight',
(e ||
'classic') as typeof visualizer.audiomotionanalyzer.gradientRight,
)
}
/>
</Group>
<CustomGradientsManager />
</Stack>
</Fieldset>
);
};
const FFTSettings = () => {
const { t } = useTranslation();
const { updateProperty, visualizer } = useUpdateAudioMotionAnalyzer();
return (
<Fieldset legend={t('visualizer.fft')}>
<Group grow>
<VisualizerSelect
data={fftSizeOptions.map((option) => ({
label: option.label,
value: option.value as string,
}))}
defaultValue={visualizer.audiomotionanalyzer.fftSize.toString()}
label={t('visualizer.fftSize')}
onChange={(e) => updateProperty('fftSize', Number(e))}
/>
<VisualizerSlider
defaultValue={visualizer.audiomotionanalyzer.smoothing}
label={t('visualizer.smoothing')}
max={1}
min={0}
onChangeEnd={(e) => updateProperty('smoothing', e)}
step={0.1}
/>
</Group>
</Fieldset>
);
};
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 (
<Fieldset legend={t('visualizer.frequencyRangeAndScaling')}>
<Group grow wrap="nowrap">
<VisualizerSelect
data={minFreqOptions.map((option) => ({
label: option.label,
value: option.value as string,
}))}
defaultValue={visualizer.audiomotionanalyzer.minFreq.toString()}
label={t('visualizer.minimumFrequency')}
onChange={(e) => updateProperty('minFreq', Number(e))}
/>
<VisualizerSelect
data={maxFreqOptions.map((option) => ({
label: option.label,
value: option.value as string,
}))}
defaultValue={visualizer.audiomotionanalyzer.maxFreq.toString()}
label={t('visualizer.maximumFrequency')}
onChange={(e) => updateProperty('maxFreq', Number(e))}
/>
<VisualizerSelect
data={translatedFrequencyScaleOptions}
defaultValue={visualizer.audiomotionanalyzer.frequencyScale}
label={t('visualizer.frequencyScale')}
onChange={(e) =>
updateProperty(
'frequencyScale',
(e || 'log') as 'bark' | 'linear' | 'log' | 'mel',
)
}
/>
</Group>
</Fieldset>
);
};
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 (
<Fieldset legend={t('visualizer.sensitivity')}>
<Group grow>
<VisualizerSelect
data={translatedWeightingFilterOptions}
defaultValue={visualizer.audiomotionanalyzer.weightingFilter}
label={t('visualizer.weightingFilter')}
onChange={(e) =>
updateProperty('weightingFilter', e as 'A' | 'B' | 'C' | 'D' | 'Z')
}
/>
<VisualizerSlider
defaultValue={visualizer.audiomotionanalyzer.minDecibels}
label={t('visualizer.minimumDecibels')}
max={-60}
min={-120}
onChangeEnd={(e) => updateProperty('minDecibels', e)}
step={1}
/>
<VisualizerSlider
defaultValue={visualizer.audiomotionanalyzer.maxDecibels}
label={t('visualizer.maximumDecibels')}
max={0}
min={-40}
onChangeEnd={(e) => updateProperty('maxDecibels', e)}
step={1}
/>
</Group>
</Fieldset>
);
};
const LinearAmplitudeSettings = () => {
const { t } = useTranslation();
const { updateProperty, visualizer } = useUpdateAudioMotionAnalyzer();
const isLinearBoostDisabled = !visualizer.audiomotionanalyzer.linearAmplitude;
return (
<Fieldset legend={t('visualizer.linearAmplitude')}>
<Group grow>
<VisualizerToggle
label={t('visualizer.linearAmplitude')}
onChange={(value) => updateProperty('linearAmplitude', value)}
value={visualizer.audiomotionanalyzer.linearAmplitude}
/>
<VisualizerSlider
defaultValue={visualizer.audiomotionanalyzer.linearBoost}
disabled={isLinearBoostDisabled}
label={t('visualizer.linearBoost')}
max={4}
min={1}
onChangeEnd={(e) => updateProperty('linearBoost', e)}
step={0.1}
/>
</Group>
</Fieldset>
);
};
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 (
<Fieldset legend={t('visualizer.peakBehavior')}>
<Stack>
<Group grow>
{peakToggles.map((toggle) => (
<VisualizerToggle
disabled={isToggleDisabled(toggle)}
key={toggle.value}
label={toggle.label}
onChange={(value) =>
updateProperty(
toggle.value as keyof typeof visualizer.audiomotionanalyzer,
value,
)
}
value={visualizer.audiomotionanalyzer[toggle.value]}
/>
))}
</Group>
<Group grow>
<VisualizerSlider
defaultValue={visualizer.audiomotionanalyzer.gravity}
disabled={isGravityDisabled}
label={t('visualizer.gravity')}
max={20}
min={0.1}
onChangeEnd={(e) => updateProperty('gravity', e)}
/>
<VisualizerSlider
defaultValue={visualizer.audiomotionanalyzer.peakFadeTime}
disabled={isPeakFadeTimeDisabled}
label={t('visualizer.peakFadeTime')}
max={2000}
min={0}
onChangeEnd={(e) => updateProperty('peakFadeTime', e)}
step={1}
/>
<VisualizerSlider
defaultValue={visualizer.audiomotionanalyzer.peakHoldTime}
disabled={isPeakHoldTimeDisabled}
label={t('visualizer.peakHoldTime')}
max={1000}
min={0}
onChangeEnd={(e) => updateProperty('peakHoldTime', e)}
step={1}
/>
</Group>
</Stack>
</Fieldset>
);
};
const RadialSpectrumSettings = () => {
const { t } = useTranslation();
const { updateProperty, visualizer } = useUpdateAudioMotionAnalyzer();
const isRadialInvertDisabled = !visualizer.audiomotionanalyzer.radial;
const isRadiusDisabled = !visualizer.audiomotionanalyzer.radial;
const isSpinSpeedDisabled = !visualizer.audiomotionanalyzer.radial;
return (
<Fieldset legend={t('visualizer.radialSpectrum')}>
<Group grow>
<VisualizerToggle
label={t('visualizer.radial')}
onChange={(value) => updateProperty('radial', value)}
value={visualizer.audiomotionanalyzer.radial}
/>
<VisualizerToggle
disabled={isRadialInvertDisabled}
label={t('visualizer.radialInvert')}
onChange={(value) => updateProperty('radialInvert', value)}
value={visualizer.audiomotionanalyzer.radialInvert}
/>
<VisualizerSlider
defaultValue={visualizer.audiomotionanalyzer.radius}
disabled={isRadiusDisabled}
label={t('visualizer.radius')}
max={1}
min={0}
onChangeEnd={(e) => updateProperty('radius', e)}
step={0.05}
/>
<VisualizerSlider
defaultValue={visualizer.audiomotionanalyzer.spinSpeed}
disabled={isSpinSpeedDisabled}
label={t('visualizer.spinSpeed')}
max={5}
min={-5}
onChangeEnd={(e) => updateProperty('spinSpeed', e)}
step={0.1}
/>
</Group>
</Fieldset>
);
};
const ReflexMirrorSettings = () => {
const { t } = useTranslation();
const { updateProperty, visualizer } = useUpdateAudioMotionAnalyzer();
return (
<Fieldset legend={t('visualizer.reflexMirror')}>
<Group grow>
<VisualizerToggle
label={t('visualizer.reflexFit')}
onChange={(value) => updateProperty('reflexFit', value)}
value={visualizer.audiomotionanalyzer.reflexFit}
/>
<VisualizerSlider
defaultValue={visualizer.audiomotionanalyzer.reflexRatio}
label={t('visualizer.reflexRatio')}
max={1}
min={0}
onChangeEnd={(e) => updateProperty('reflexRatio', e)}
step={0.1}
/>
<VisualizerSlider
defaultValue={visualizer.audiomotionanalyzer.reflexAlpha}
label={t('visualizer.reflexAlpha')}
max={1}
min={0}
onChangeEnd={(e) => updateProperty('reflexAlpha', e)}
step={0.05}
/>
<VisualizerSlider
defaultValue={visualizer.audiomotionanalyzer.reflexBright}
label={t('visualizer.reflexBrightness')}
max={2}
min={0}
onChangeEnd={(e) => updateProperty('reflexBright', e)}
step={0.1}
/>
<VisualizerSlider
defaultValue={visualizer.audiomotionanalyzer.mirror}
label={t('visualizer.mirror')}
max={1}
min={-1}
onChangeEnd={(e) => updateProperty('mirror', e)}
step={1}
/>
</Group>
</Fieldset>
);
};
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 (
<Fieldset legend={t('visualizer.miscellaneousSettings')}>
<Group>
{AMA_TOGGLES.map((toggle) => (
<VisualizerToggle
disabled={isToggleDisabled(toggle)}
key={toggle.value}
label={toggle.label}
onChange={(value) =>
updateProperty(
toggle.value as keyof typeof visualizer.audiomotionanalyzer,
value,
)
}
value={
visualizer.audiomotionanalyzer[
toggle.value as keyof typeof visualizer.audiomotionanalyzer
] as boolean
}
/>
))}
</Group>
</Fieldset>
);
};
const ButterchurnGeneralSettings = () => {
const { t } = useTranslation();
const { updateProperty, visualizer } = useUpdateButterchurn();
const presetOptions = useButterchurnPresetOptions();
return (
<Fieldset legend={t('visualizer.general')}>
<Stack>
<Group grow>
<VisualizerSelect
data={presetOptions}
label={t('visualizer.selectPreset')}
onChange={(value) => {
updateProperty('currentPreset', value || undefined);
}}
value={visualizer.butterchurn.currentPreset}
/>
</Group>
<Group grow>
<VisualizerSlider
defaultValue={visualizer.butterchurn.blendTime}
label={t('visualizer.blendTime')}
max={10}
min={0}
onChangeEnd={(e) => updateProperty('blendTime', e)}
step={0.1}
/>
<VisualizerSlider
defaultValue={visualizer.butterchurn.maxFPS}
label={t('visualizer.maxFPS')}
max={144}
min={0}
onChangeEnd={(e) => updateProperty('maxFPS', e)}
step={1}
/>
<VisualizerSlider
defaultValue={visualizer.butterchurn.opacity}
label={t('visualizer.opacity')}
max={1}
min={0}
onChangeEnd={(e) => updateProperty('opacity', e)}
step={0.01}
/>
</Group>
</Stack>
</Fieldset>
);
};
const ButterChurnCycleSettings = () => {
const { t } = useTranslation();
const { updateProperty, visualizer } = useUpdateButterchurn();
const presetOptions = useButterchurnPresetOptions();
return (
<Fieldset legend={t('visualizer.cyclePresets')}>
<Stack>
<Group grow>
<VisualizerToggle
label={t('visualizer.cyclePresets')}
onChange={(checked) => updateProperty('cyclePresets', checked)}
value={visualizer.butterchurn.cyclePresets}
/>
<VisualizerToggle
disabled={!visualizer.butterchurn.cyclePresets}
label={t('visualizer.includeAllPresets')}
onChange={(checked) => updateProperty('includeAllPresets', checked)}
value={visualizer.butterchurn.includeAllPresets}
/>
<VisualizerToggle
disabled={!visualizer.butterchurn.cyclePresets}
label={t('visualizer.randomizeNextPreset')}
onChange={(checked) => updateProperty('randomizeNextPreset', checked)}
value={visualizer.butterchurn.randomizeNextPreset}
/>
</Group>
<MultiSelect
data={presetOptions}
disabled={
!visualizer.butterchurn.cyclePresets ||
visualizer.butterchurn.includeAllPresets
}
label={t('visualizer.selectedPresets')}
onChange={(values) => updateProperty('selectedPresets', values)}
searchable
value={visualizer.butterchurn.selectedPresets}
/>
<MultiSelect
data={presetOptions}
disabled={!visualizer.butterchurn.cyclePresets}
label={t('visualizer.ignoredPresets')}
onChange={(values) => updateProperty('ignoredPresets', values)}
searchable
value={visualizer.butterchurn.ignoredPresets}
/>
<Group grow>
<VisualizerSlider
defaultValue={visualizer.butterchurn.cycleTime}
disabled={!visualizer.butterchurn.cyclePresets}
label={t('visualizer.cycleTime')}
max={300}
min={1}
onChangeEnd={(e) => updateProperty('cycleTime', e)}
step={1}
/>
</Group>
</Stack>
</Fieldset>
);
};