mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-08 04:50:12 +02:00
310 lines
11 KiB
TypeScript
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>
|
|
);
|
|
};
|