+
{webAudio ? (
>}>
{visualizerType === 'butterchurn' ? (
diff --git a/src/renderer/features/visualizer/components/audiomotionanalyzer/visualizer-settings-form.tsx b/src/renderer/features/visualizer/components/audiomotionanalyzer/visualizer-settings-form.tsx
index 5e77a6626..0f596ecf5 100644
--- a/src/renderer/features/visualizer/components/audiomotionanalyzer/visualizer-settings-form.tsx
+++ b/src/renderer/features/visualizer/components/audiomotionanalyzer/visualizer-settings-form.tsx
@@ -5,7 +5,10 @@ import { useTranslation } from 'react-i18next';
import styles from './visualizer-settings-form.module.css';
import i18n from '/@/i18n/i18n';
-import { getButterchurnPresetOptions } from '/@/renderer/features/visualizer/components/butternchurn/visualizer';
+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';
@@ -32,8 +35,8 @@ let butterchurnPresetOptionsCache: ButterchurnPresetOption[] | null = null;
const loadButterchurnPresetOptions = async (): Promise => {
if (butterchurnPresetOptionsCache) return butterchurnPresetOptionsCache;
- const mod = await import('butterchurn-presets');
- const presets = getButterchurnPresetOptions((mod as any).default ?? mod);
+ const mergedPresets = await loadAllButterchurnPresets();
+ const presets = getButterchurnPresetOptions(mergedPresets);
const presetNames = Object.keys(presets);
butterchurnPresetOptionsCache = presetNames.map((presetName) => ({
@@ -2186,6 +2189,7 @@ const ButterChurnCycleSettings = () => {
}
label={t('visualizer.selectedPresets')}
onChange={(values) => updateProperty('selectedPresets', values)}
+ searchable
value={visualizer.butterchurn.selectedPresets}
/>
{
disabled={!visualizer.butterchurn.cyclePresets}
label={t('visualizer.ignoredPresets')}
onChange={(values) => updateProperty('ignoredPresets', values)}
+ searchable
value={visualizer.butterchurn.ignoredPresets}
/>
diff --git a/src/renderer/features/visualizer/components/butternchurn/butterchurn.d.ts b/src/renderer/features/visualizer/components/butternchurn/butterchurn.d.ts
index ac3ef8460..fd5b59f46 100644
--- a/src/renderer/features/visualizer/components/butternchurn/butterchurn.d.ts
+++ b/src/renderer/features/visualizer/components/butternchurn/butterchurn.d.ts
@@ -5,3 +5,15 @@ declare module 'butterchurn' {
declare module 'butterchurn-presets' {
export default butterchurnPresets;
}
+
+declare module 'butterchurn-presets/dist/base.js' {
+ export default butterchurnPresets;
+}
+
+declare module 'butterchurn-presets/dist/extra.js' {
+ export default butterchurnPresets;
+}
+
+declare module 'butterchurn-presets/dist/md1.js' {
+ export default butterchurnPresets;
+}
diff --git a/src/renderer/features/visualizer/components/butternchurn/visualizer.tsx b/src/renderer/features/visualizer/components/butternchurn/visualizer.tsx
index 7235d4ff6..90287d43a 100644
--- a/src/renderer/features/visualizer/components/butternchurn/visualizer.tsx
+++ b/src/renderer/features/visualizer/components/butternchurn/visualizer.tsx
@@ -40,6 +40,31 @@ export function getButterchurnPresetOptions(presets: Record) {
);
}
+// Merges every non-image preset bundle shipped in butterchurn-presets (base, extra,
+// md1). Skips image.js/imageData.js on purpose since those presets
+// depend on separate texture data and are more likely to break/require extra wiring.
+let mergedPresetsCache: null | Record = null;
+
+export async function loadAllButterchurnPresets(): Promise> {
+ if (mergedPresetsCache) return mergedPresetsCache;
+
+ const [base, extra, md1] = await Promise.all([
+ import('butterchurn-presets/dist/base.js'),
+ import('butterchurn-presets/dist/extra.js'),
+ import('butterchurn-presets/dist/md1.js'),
+ ]);
+
+ const presets = {
+ ...base.default,
+ ...extra.default,
+ ...md1.default,
+ };
+
+ mergedPresetsCache = presets;
+
+ return presets;
+}
+
const VisualizerInner = () => {
const { webAudio } = useWebAudio();
const canvasRef = createRef();
@@ -72,15 +97,14 @@ const VisualizerInner = () => {
const loadLibraries = async () => {
try {
- const [butterchurnModule, presetsModule] = await Promise.all([
+ const [butterchurnModule, mergedPresets] = await Promise.all([
import('butterchurn'),
- import('butterchurn-presets'),
+ loadAllButterchurnPresets(),
]);
if (isMounted) {
butterchurnRef.current = butterchurnModule.default;
- butterchurnPresetsRef.current = butterchurnPresetsRef.current =
- getButterchurnPresetOptions(presetsModule.default);
+ butterchurnPresetsRef.current = getButterchurnPresetOptions(mergedPresets);
setLibrariesLoaded(true);
}
@@ -533,11 +557,9 @@ export const Visualizer = () => {
const loadPresets = async () => {
try {
- const presetsModule = await import('butterchurn-presets');
+ const mergedPresets = await loadAllButterchurnPresets();
if (isMounted) {
- butterchurnPresetsRef.current = getButterchurnPresetOptions(
- presetsModule.default,
- );
+ butterchurnPresetsRef.current = getButterchurnPresetOptions(mergedPresets);
setPresetsLoaded(true);
}
} catch (error) {
@@ -581,6 +603,15 @@ export const Visualizer = () => {
setStore({ expanded: false, visualizerExpanded: !visualizerExpanded });
};
+ const pickRandomPreset = (presetList: string[], currentPresetName: string | undefined) => {
+ const availablePresets =
+ presetList.length > 1
+ ? presetList.filter((name) => name !== currentPresetName)
+ : presetList;
+ const randomIndex = Math.floor(Math.random() * availablePresets.length);
+ return availablePresets[randomIndex];
+ };
+
const handleNextPreset = () => {
if (!presetsLoaded) return;
@@ -588,10 +619,16 @@ export const Visualizer = () => {
if (presetList.length === 0) return;
const currentPresetName = useSettingsStore.getState().visualizer.butterchurn.currentPreset;
- const currentIndex = currentPresetName ? presetList.indexOf(currentPresetName) : -1;
- const nextIndex =
- currentIndex >= 0 && currentIndex < presetList.length - 1 ? currentIndex + 1 : 0;
- const nextPresetName = presetList[nextIndex];
+
+ let nextPresetName: string;
+ if (butterchurnSettings.randomizeNextPreset) {
+ nextPresetName = pickRandomPreset(presetList, currentPresetName);
+ } else {
+ const currentIndex = currentPresetName ? presetList.indexOf(currentPresetName) : -1;
+ const nextIndex =
+ currentIndex >= 0 && currentIndex < presetList.length - 1 ? currentIndex + 1 : 0;
+ nextPresetName = presetList[nextIndex];
+ }
setSettings({
visualizer: {
@@ -609,9 +646,15 @@ export const Visualizer = () => {
if (presetList.length === 0) return;
const currentPresetName = useSettingsStore.getState().visualizer.butterchurn.currentPreset;
- const currentIndex = currentPresetName ? presetList.indexOf(currentPresetName) : -1;
- const prevIndex = currentIndex > 0 ? currentIndex - 1 : presetList.length - 1;
- const prevPresetName = presetList[prevIndex];
+
+ let prevPresetName: string;
+ if (butterchurnSettings.randomizeNextPreset) {
+ prevPresetName = pickRandomPreset(presetList, currentPresetName);
+ } else {
+ const currentIndex = currentPresetName ? presetList.indexOf(currentPresetName) : -1;
+ const prevIndex = currentIndex > 0 ? currentIndex - 1 : presetList.length - 1;
+ prevPresetName = presetList[prevIndex];
+ }
setSettings({
visualizer: {
diff --git a/src/renderer/hooks/use-fullscreen-toggle.ts b/src/renderer/hooks/use-fullscreen-toggle.ts
new file mode 100644
index 000000000..098368631
--- /dev/null
+++ b/src/renderer/hooks/use-fullscreen-toggle.ts
@@ -0,0 +1,49 @@
+import { useEffect } from 'react';
+
+import { useFullScreenPlayerStore } from '/@/renderer/store/full-screen-player.store';
+
+/**
+ * Id applied to the expanded visualizer's inner container so that F11 can target it
+ * directly. Fullscreening that element (rather than the whole document) promotes it to
+ * the browser's top layer, so it covers the window bar and playerbar instead of being
+ * boxed in by them.
+ */
+export const VISUALIZER_FULLSCREEN_TARGET_ID = 'visualizer-fullscreen-target';
+
+export const useFullscreenToggle = () => {
+ useEffect(() => {
+ const toggleFullscreen = () => {
+ // Already fullscreen: back out, regardless of what was fullscreened.
+ if (document.fullscreenElement) {
+ document.exitFullscreen().catch(() => {});
+ return;
+ }
+
+ const { visualizerExpanded } = useFullScreenPlayerStore.getState();
+
+ const visualizerTarget = visualizerExpanded
+ ? document.getElementById(VISUALIZER_FULLSCREEN_TARGET_ID)
+ : null;
+
+ // Expanded visualizer -> fullscreen just the visualizer.
+ // Anything else -> normal whole-window fullscreen.
+ const target = visualizerTarget ?? document.documentElement;
+
+ target.requestFullscreen().catch(() => {});
+ };
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key !== 'F11') return;
+
+ event.preventDefault();
+ toggleFullscreen();
+ };
+
+ // Capture phase so this still fires while a text input has focus.
+ window.addEventListener('keydown', handleKeyDown, true);
+
+ return () => {
+ window.removeEventListener('keydown', handleKeyDown, true);
+ };
+ }, []);
+};