Files
feishin/src/renderer/features/visualizer/components/audiomotionanalyzer/visualizer.tsx
T
2026-01-17 07:32:16 -08:00

310 lines
11 KiB
TypeScript

import { createRef, useCallback, useEffect, useMemo, 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 { useAccent, useSettingsStore } from '/@/renderer/store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
const VisualizerInner = () => {
const { webAudio } = useWebAudio();
const canvasRef = createRef<HTMLDivElement>();
const accent = useAccent();
const visualizer = useSettingsStore((store) => store.visualizer);
const opacity = useSettingsStore((store) => store.visualizer.audiomotionanalyzer.opacity);
const [motion, setMotion] = useState<any>();
const [libraryLoaded, setLibraryLoaded] = useState(false);
const AudioMotionAnalyzerRef = useRef<any>(null);
useEffect(() => {
let isMounted = true;
const loadLibrary = async () => {
try {
const module = await import('audiomotion-analyzer');
if (isMounted) {
AudioMotionAnalyzerRef.current = module.default;
setLibraryLoaded(true);
}
} catch (error) {
console.error('Failed to load AudioMotionAnalyzer library:', error);
}
};
loadLibrary();
return () => {
isMounted = false;
};
}, []);
// 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 transformGradientForVisualizer = useCallback(
(gradient: {
colorStops: Array<{
color: string;
level?: number;
levelEnabled?: boolean;
pos?: number;
positionEnabled?: boolean;
}>;
dir?: string;
}): {
colorStops: (string | { color: string; level?: number; pos?: number })[];
dir?: string;
} => {
const transformedColorStops = gradient.colorStops.map((stop) => {
// If neither position nor level is enabled, return just the color string
if (!stop.positionEnabled && !stop.levelEnabled) {
return stop.color;
}
// Otherwise, return an object with only enabled properties
const transformedStop: { color: string; level?: number; pos?: number } = {
color: stop.color,
};
if (stop.positionEnabled && stop.pos !== undefined) {
transformedStop.pos = stop.pos;
}
if (stop.levelEnabled && stop.level !== undefined) {
transformedStop.level = stop.level;
}
return transformedStop;
});
return {
colorStops: transformedColorStops,
...(gradient.dir ? { dir: gradient.dir } : {}),
};
},
[],
);
const registerCustomGradients = useCallback(
(audioMotionInstance: any) => {
if (visualizer.type !== 'audiomotionanalyzer') {
return;
}
const customGradients = visualizer.audiomotionanalyzer.customGradients || [];
customGradients.forEach((gradient) => {
try {
const gradientConfig = transformGradientForVisualizer(gradient);
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, transformGradientForVisualizer],
);
useEffect(() => {
const { context, gains } = webAudio || {};
let audioMotion: any | undefined;
if (gains && context && canvasRef.current && !motion && libraryLoaded) {
const AudioMotionAnalyzer = AudioMotionAnalyzerRef.current;
if (!AudioMotionAnalyzer) return;
// 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';
}
}
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 () => {
if (motion) {
motion.destroy();
setMotion(undefined);
}
};
}, [
accent,
canvasRef,
registerCustomGradients,
webAudio,
visualizer,
options,
isCustomGradient,
motion,
libraryLoaded,
]);
// 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 <div className={styles.visualizer} ref={canvasRef} style={{ opacity }} />;
};
export const Visualizer = () => {
return (
<div className={styles.container}>
<ActionIcon
className={styles.settingsIcon}
icon="settings2"
iconProps={{ size: 'lg' }}
onClick={openVisualizerSettingsModal}
pos="absolute"
right="var(--theme-spacing-sm)"
top="var(--theme-spacing-sm)"
variant="subtle"
/>
<ComponentErrorBoundary>
<VisualizerInner />
</ComponentErrorBoundary>
</div>
);
};