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); + }; + }, []); +};