From e402bd5424fd83c6cdbf6b4cf1b5574ab381076d Mon Sep 17 00:00:00 2001 From: jaxi Date: Thu, 30 Jul 2026 23:49:24 -0400 Subject: [PATCH] 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 --- package.json | 2 +- src/renderer/app.tsx | 8 ++ .../full-screen-visualizer.module.css | 6 ++ .../components/full-screen-visualizer.tsx | 19 ++++- .../visualizer-settings-form.tsx | 11 ++- .../components/butternchurn/butterchurn.d.ts | 12 +++ .../components/butternchurn/visualizer.tsx | 73 +++++++++++++++---- src/renderer/hooks/use-fullscreen-toggle.ts | 49 +++++++++++++ 8 files changed, 159 insertions(+), 21 deletions(-) create mode 100644 src/renderer/hooks/use-fullscreen-toggle.ts diff --git a/package.json b/package.json index e29c94b54..9e711142d 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "main": "./out/main/index.js", "scripts": { "build": "pnpm run build:electron && pnpm run build:remote", - "build:electron": "electron-vite build", + "build:electron": "cross-env NODE_OPTIONS=--max-old-space-size=4096 electron-vite build", "build:remote": "vite build --config remote.vite.config.ts", "build:web": "vite build --config web.vite.config.ts", "dev": "electron-vite dev", diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index ded22e1fc..d19930294 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -12,6 +12,7 @@ import { lazy, memo, Suspense, useEffect, useMemo, useRef, useState } from 'reac import i18n from '/@/i18n/i18n'; import { WebAudioContext } from '/@/renderer/features/player/context/webaudio-context'; import { useCheckForUpdates } from '/@/renderer/hooks/use-check-for-updates'; +import { useFullscreenToggle } from '/@/renderer/hooks/use-fullscreen-toggle'; import { useNativeMenuSync } from '/@/renderer/hooks/use-native-menu-sync'; import { useSyncSettingsToMain } from '/@/renderer/hooks/use-sync-settings-to-main'; import { AppRouter } from '/@/renderer/router/app-router'; @@ -118,6 +119,7 @@ const AppEffects = () => ( + ); @@ -262,6 +264,12 @@ const NativeMenuSyncEffect = () => { return null; }; +const FullscreenToggleEffect = () => { + useFullscreenToggle(); + + return null; +}; + const InputFocusEffect = () => { useEffect(() => { if (!isElectron()) return; diff --git a/src/renderer/features/player/components/full-screen-visualizer.module.css b/src/renderer/features/player/components/full-screen-visualizer.module.css index 901c3bf4b..aa027ea9d 100644 --- a/src/renderer/features/player/components/full-screen-visualizer.module.css +++ b/src/renderer/features/player/components/full-screen-visualizer.module.css @@ -64,3 +64,9 @@ line-height: 1.3; color: #ddd; } + +.visualizer-container:fullscreen { + width: 100vw; + height: 100vh; + background: var(--theme-colors-background); +} diff --git a/src/renderer/features/player/components/full-screen-visualizer.tsx b/src/renderer/features/player/components/full-screen-visualizer.tsx index 7e8520c73..0ed6261a6 100644 --- a/src/renderer/features/player/components/full-screen-visualizer.tsx +++ b/src/renderer/features/player/components/full-screen-visualizer.tsx @@ -1,10 +1,11 @@ import { motion, Variants } from 'motion/react'; -import { lazy, memo, ReactNode, Suspense, useLayoutEffect, useRef } from 'react'; +import { lazy, memo, ReactNode, Suspense, useEffect, useLayoutEffect, useRef } from 'react'; import { useLocation } from 'react-router'; import styles from './full-screen-visualizer.module.css'; import { FullScreenVisualizerSongInfo } from '/@/renderer/features/player/components/full-screen-visualizer-song-info'; +import { VISUALIZER_FULLSCREEN_TARGET_ID } from '/@/renderer/hooks/use-fullscreen-toggle'; import { useHotkeys } from '/@/renderer/hooks/use-hotkeys'; import { useIsMobile } from '/@/renderer/hooks/use-is-mobile'; import { useFullScreenPlayerStoreActions } from '/@/renderer/store/full-screen-player.store'; @@ -139,11 +140,25 @@ export const FullScreenVisualizer = () => { const isOpenedRef = useRef(null); const handleCloseVisualizer = () => { + // While fullscreen, Escape is the browser's own "leave fullscreen" gesture. + // Let it drop back to the expanded-but-windowed visualizer instead of closing. + if (document.fullscreenElement) return; + setStore({ visualizerExpanded: false }); }; useHotkeys([['Escape', handleCloseVisualizer]]); + // Never leave the window stuck in fullscreen if the visualizer goes away while + // fullscreened (route change, close button, etc.). + useEffect(() => { + return () => { + if (document.fullscreenElement) { + document.exitFullscreen().catch(() => {}); + } + }; + }, []); + useLayoutEffect(() => { if (isOpenedRef.current !== null) { setStore({ visualizerExpanded: false }); @@ -154,7 +169,7 @@ export const FullScreenVisualizer = () => { return ( -
+
{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); + }; + }, []); +};