add fullscreen visualizer (#1546)

This commit is contained in:
jeffvli
2026-01-18 02:17:55 -08:00
parent 27a5153b8a
commit 0e388dabf5
14 changed files with 555 additions and 34 deletions
@@ -46,7 +46,10 @@ const ButterchurnVisualizer = lazy(() =>
export const SidebarPlayQueue = () => {
const tableRef = useRef<ItemListHandle | null>(null);
const [search, setSearch] = useState<string | undefined>(undefined);
const { expanded: isFullScreenPlayerExpanded } = useFullScreenPlayerStore();
const {
expanded: isFullScreenPlayerExpanded,
visualizerExpanded: isFullScreenVisualizerExpanded,
} = useFullScreenPlayerStore();
const [shouldRender, setShouldRender] = useState(!isFullScreenPlayerExpanded);
const combinedLyricsAndVisualizer = useCombinedLyricsAndVisualizer();
const showLyricsInSidebar = useShowLyricsInSidebar();
@@ -60,7 +63,7 @@ export const SidebarPlayQueue = () => {
const shouldAddTopMargin = isElectron() && windowBarStyle === Platform.WEB;
useEffect(() => {
if (isFullScreenPlayerExpanded) {
if (isFullScreenPlayerExpanded || isFullScreenVisualizerExpanded) {
// Immediately hide when fullscreen player opens
setShouldRender(false);
return undefined;
@@ -74,7 +77,7 @@ export const SidebarPlayQueue = () => {
clearTimeout(timeoutId);
};
}
}, [isFullScreenPlayerExpanded]);
}, [isFullScreenPlayerExpanded, isFullScreenVisualizerExpanded]);
const [defaultLayout, onLayoutChange] = usePersistence({
debounce: 300,
@@ -237,7 +237,7 @@ const Controls = () => {
const lyricConfig = { ...lyricsSettings, ...displaySettings };
const handleToggleFullScreenPlayer = () => {
setStore({ expanded: !expanded });
setStore({ expanded: !expanded, visualizerExpanded: false });
};
const handleLyricsSettings = (property: string, value: any) => {
@@ -0,0 +1,90 @@
import { motion } from 'motion/react';
import { useEffect, useRef, useState } from 'react';
import styles from './full-screen-visualizer.module.css';
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
import { usePlayerSong } from '/@/renderer/store/player.store';
import { Stack } from '/@/shared/components/stack/stack';
import { TextTitle } from '/@/shared/components/text-title/text-title';
import { Text } from '/@/shared/components/text/text';
export const FullScreenVisualizerSongInfo = () => {
const currentSong = usePlayerSong();
const [showSongInfo, setShowSongInfo] = useState(false);
const timeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
usePlayerEvents(
{
onCurrentSongChange: () => {
setShowSongInfo(true);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
setShowSongInfo(false);
}, 3000);
},
},
[],
);
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
const overlayVariants = {
hidden: {
opacity: 0,
transition: {
duration: 1.5,
ease: 'easeInOut' as const,
},
},
visible: {
opacity: 1,
transition: {
duration: 0.5,
ease: 'easeInOut' as const,
},
},
};
if (!currentSong) {
return null;
}
return (
<>
<motion.div
animate={showSongInfo ? 'visible' : 'hidden'}
className={styles.songInfoBackdrop}
initial="hidden"
variants={overlayVariants}
/>
<motion.div
animate={showSongInfo ? 'visible' : 'hidden'}
className={styles.songInfoOverlay}
initial="hidden"
variants={overlayVariants}
>
<Stack align="center" gap="lg" justify="center">
<TextTitle className={styles.songInfoTitle} fw="800" isNoSelect order={1}>
{currentSong.name}
</TextTitle>
{currentSong.artistName && (
<Text className={styles.songInfoArtist} isNoSelect>
{currentSong.artistName}
</Text>
)}
</Stack>
</motion.div>
</>
);
};
@@ -0,0 +1,66 @@
.container {
position: absolute;
top: 0;
left: 0;
z-index: 200;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
overflow: hidden;
background: var(--theme-colors-background);
}
.controls-container {
z-index: 201;
background: rgb(var(--theme-colors-background-transparent), 80%);
}
.visualizer-container {
position: relative;
flex: 1;
width: 100%;
height: 100%;
overflow: hidden;
}
.song-info-backdrop {
position: absolute;
top: 0;
left: 0;
z-index: 50;
width: 100%;
height: 100%;
pointer-events: none;
background: rgb(0 0 0 / 60%);
}
.song-info-overlay {
position: absolute;
top: 50%;
left: 50%;
z-index: 51;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
max-width: 60%;
padding: var(--theme-spacing-lg);
color: var(--theme-colors-foreground);
text-align: center;
text-shadow: 0 2px 8px rgb(0 0 0 / 50%);
pointer-events: none;
transform: translate(-50%, -50%);
}
.song-info-title {
font-size: clamp(2rem, 8vw, 6rem);
line-height: 1.2;
color: #ddd;
}
.song-info-artist {
font-size: clamp(1.25rem, 5vw, 3.5rem);
line-height: 1.3;
color: #ddd;
}
@@ -0,0 +1,171 @@
import { motion, Variants } from 'motion/react';
import { lazy, memo, ReactNode, Suspense, 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 { useIsMobile } from '/@/renderer/hooks/use-is-mobile';
import { useFullScreenPlayerStoreActions } from '/@/renderer/store/full-screen-player.store';
import {
usePlaybackSettings,
useSettingsStore,
useWindowSettings,
} from '/@/renderer/store/settings.store';
import { useHotkeys } from '/@/shared/hooks/use-hotkeys';
import { Platform, PlayerType } from '/@/shared/types/types';
const AudioMotionAnalyzerVisualizer = lazy(() =>
import('../../visualizer/components/audiomotionanalyzer/visualizer').then((module) => ({
default: module.Visualizer,
})),
);
const ButterchurnVisualizer = lazy(() =>
import('../../visualizer/components/butternchurn/visualizer').then((module) => ({
default: module.Visualizer,
})),
);
const containerVariants: Variants = {
closed: (custom) => {
const { isMobile, windowBarStyle } = custom;
const height =
windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS
? 'calc(100vh - 120px)'
: 'calc(100vh - 90px)';
if (isMobile) {
return {
height,
position: 'absolute',
top: '100vh',
transition: {
duration: 0.5,
ease: 'easeInOut',
},
width: '100vw',
y: 0,
};
}
return {
height,
position: 'absolute',
top: '100vh',
transition: {
duration: 0.5,
ease: 'easeInOut',
},
width: '100vw',
y: 0,
};
},
open: (custom) => {
const { isMobile, windowBarStyle } = custom;
const height =
windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS
? 'calc(100vh - 120px)'
: 'calc(100vh - 90px)';
const topOffset =
windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS
? '30px'
: '0px';
if (isMobile) {
return {
height,
left: 0,
position: 'absolute',
top: topOffset,
transition: {
delay: 0.1,
duration: 0.5,
ease: 'easeInOut',
},
width: '100vw',
y: 0,
};
}
return {
height,
left: 0,
position: 'absolute',
top: 0,
transition: {
delay: 0.1,
duration: 0.5,
ease: 'easeInOut',
},
width: '100vw',
y: 0,
};
},
};
interface VisualizerContainerProps {
children: ReactNode;
isMobile?: boolean;
windowBarStyle: Platform;
}
const VisualizerContainer = memo(
({ children, isMobile, windowBarStyle }: VisualizerContainerProps) => {
return (
<motion.div
animate="open"
className={styles.container}
custom={{ isMobile, windowBarStyle }}
exit="closed"
initial="closed"
transition={{ duration: 2 }}
variants={containerVariants}
>
{children}
</motion.div>
);
},
);
VisualizerContainer.displayName = 'VisualizerContainer';
export const FullScreenVisualizer = () => {
const { setStore } = useFullScreenPlayerStoreActions();
const { windowBarStyle } = useWindowSettings();
const { type, webAudio } = usePlaybackSettings();
const visualizerType = useSettingsStore((store) => store.visualizer.type);
const isMobile = useIsMobile();
const location = useLocation();
const isOpenedRef = useRef<boolean | null>(null);
const handleCloseVisualizer = () => {
setStore({ visualizerExpanded: false });
};
useHotkeys([['Escape', handleCloseVisualizer]]);
useLayoutEffect(() => {
if (isOpenedRef.current !== null) {
setStore({ visualizerExpanded: false });
}
isOpenedRef.current = true;
}, [location, setStore]);
return (
<VisualizerContainer isMobile={isMobile} windowBarStyle={windowBarStyle}>
<div className={styles.visualizerContainer}>
{type === PlayerType.WEB && webAudio ? (
<Suspense fallback={<></>}>
{visualizerType === 'butterchurn' ? (
<ButterchurnVisualizer />
) : (
<AudioMotionAnalyzerVisualizer />
)}
</Suspense>
) : null}
<FullScreenVisualizerSongInfo />
</div>
</VisualizerContainer>
);
};
@@ -14,19 +14,21 @@
margin: auto;
}
&:hover {
.settings-icon {
opacity: 1;
}
}
}
.container .settings-icon {
.icon-group {
z-index: 100;
}
.icon-group > * {
opacity: 0;
transition: opacity 0.2s ease-in-out;
}
.container:hover .icon-group > * {
opacity: 1;
}
.visualizer {
width: 100%;
max-width: 100%;
@@ -6,7 +6,12 @@ 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 {
useFullScreenPlayerStore,
useFullScreenPlayerStoreActions,
} from '/@/renderer/store/full-screen-player.store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Group } from '/@/shared/components/group/group';
const VisualizerInner = () => {
const { webAudio } = useWebAudio();
@@ -289,18 +294,35 @@ const VisualizerInner = () => {
};
export const Visualizer = () => {
const { visualizerExpanded } = useFullScreenPlayerStore();
const { setStore } = useFullScreenPlayerStoreActions();
const handleToggleFullscreen = () => {
setStore({ expanded: false, visualizerExpanded: !visualizerExpanded });
};
return (
<div className={styles.container}>
<ActionIcon
className={styles.settingsIcon}
icon="settings2"
iconProps={{ size: 'lg' }}
onClick={openVisualizerSettingsModal}
<Group
className={styles.iconGroup}
gap="xs"
pos="absolute"
right="var(--theme-spacing-sm)"
top="var(--theme-spacing-sm)"
variant="subtle"
/>
>
<ActionIcon
icon="expand"
iconProps={{ size: 'lg' }}
onClick={handleToggleFullscreen}
variant="subtle"
/>
<ActionIcon
icon="settings2"
iconProps={{ size: 'lg' }}
onClick={openVisualizerSettingsModal}
variant="subtle"
/>
</Group>
<ComponentErrorBoundary>
<VisualizerInner />
</ComponentErrorBoundary>
@@ -6,19 +6,21 @@
max-height: 100%;
margin: auto;
overflow: hidden;
&:hover {
.settings-icon {
opacity: 1;
}
}
background: #000;
}
.container .settings-icon {
.icon-group {
z-index: 100;
}
.icon-group > * {
opacity: 0;
}
.container:hover .icon-group > * {
opacity: 1;
}
.canvas {
display: block;
width: 100%;
@@ -34,7 +36,7 @@
z-index: 10;
padding: var(--theme-spacing-xs) var(--theme-spacing-sm);
font-weight: 500;
color: var(--theme-colors-foreground);
color: #ddd;
pointer-events: none;
background-color: rgb(0 0 0 / 50%);
border-radius: 0 var(--theme-radius-md) 0 0;
@@ -1,4 +1,4 @@
import { createRef, useEffect, useRef, useState } from 'react';
import { createRef, useCallback, useEffect, useRef, useState } from 'react';
import styles from './visualizer.module.css';
@@ -6,8 +6,13 @@ 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 { useSettingsStore, useSettingsStoreActions } from '/@/renderer/store';
import {
useFullScreenPlayerStore,
useFullScreenPlayerStoreActions,
} from '/@/renderer/store/full-screen-player.store';
import { usePlayerStatus } from '/@/renderer/store/player.store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Group } from '/@/shared/components/group/group';
import { Text } from '/@/shared/components/text/text';
import { PlayerStatus } from '/@/shared/types/types';
@@ -473,18 +478,147 @@ const VisualizerInner = () => {
};
export const Visualizer = () => {
const { visualizerExpanded } = useFullScreenPlayerStore();
const { setStore } = useFullScreenPlayerStoreActions();
const { setSettings } = useSettingsStoreActions();
const butterchurnSettings = useSettingsStore((store) => store.visualizer.butterchurn);
const [presetsLoaded, setPresetsLoaded] = useState(false);
const butterchurnPresetsRef = useRef<any>(null);
useEffect(() => {
let isMounted = true;
const loadPresets = async () => {
try {
const presetsModule = await import('butterchurn-presets');
if (isMounted) {
butterchurnPresetsRef.current = presetsModule.default;
setPresetsLoaded(true);
}
} catch (error) {
console.error('Failed to load butterchurn presets:', error);
}
};
loadPresets();
return () => {
isMounted = false;
};
}, []);
const getPresetList = useCallback(() => {
const presets = butterchurnPresetsRef.current;
if (!presets) return [];
const allPresetNames = Object.keys(presets);
let presetList = butterchurnSettings.includeAllPresets
? allPresetNames
: butterchurnSettings.selectedPresets.length > 0
? butterchurnSettings.selectedPresets.filter((name) => presets[name])
: allPresetNames;
if (butterchurnSettings.ignoredPresets && butterchurnSettings.ignoredPresets.length > 0) {
presetList = presetList.filter(
(name) => !butterchurnSettings.ignoredPresets.includes(name),
);
}
return presetList;
}, [
butterchurnSettings.includeAllPresets,
butterchurnSettings.selectedPresets,
butterchurnSettings.ignoredPresets,
]);
const handleToggleFullscreen = () => {
setStore({ expanded: false, visualizerExpanded: !visualizerExpanded });
};
const handleNextPreset = () => {
if (!presetsLoaded) return;
const presetList = getPresetList();
if (presetList.length === 0) return;
const currentPresetName = butterchurnSettings.currentPreset;
const currentIndex = currentPresetName ? presetList.indexOf(currentPresetName) : -1;
const nextIndex =
currentIndex >= 0 && currentIndex < presetList.length - 1 ? currentIndex + 1 : 0;
const nextPresetName = presetList[nextIndex];
setSettings({
visualizer: {
butterchurn: {
currentPreset: nextPresetName,
},
},
});
};
const handlePreviousPreset = () => {
if (!presetsLoaded) return;
const presetList = getPresetList();
if (presetList.length === 0) return;
const currentPresetName = butterchurnSettings.currentPreset;
const currentIndex = currentPresetName ? presetList.indexOf(currentPresetName) : -1;
const prevIndex = currentIndex > 0 ? currentIndex - 1 : presetList.length - 1;
const prevPresetName = presetList[prevIndex];
setSettings({
visualizer: {
butterchurn: {
currentPreset: prevPresetName,
},
},
});
};
return (
<div className={styles.container}>
<ActionIcon
className={styles.settingsIcon}
icon="settings2"
iconProps={{ size: 'lg' }}
onClick={openVisualizerSettingsModal}
<Group
className={styles.iconGroup}
gap="xs"
pos="absolute"
right="var(--theme-spacing-sm)"
top="var(--theme-spacing-sm)"
variant="subtle"
/>
>
<ActionIcon
icon="expand"
iconProps={{ size: 'lg' }}
onClick={handleToggleFullscreen}
variant="subtle"
/>
<ActionIcon
icon="settings2"
iconProps={{ size: 'lg' }}
onClick={openVisualizerSettingsModal}
variant="subtle"
/>
</Group>
<Group
className={styles.iconGroup}
gap="xs"
pos="absolute"
right="var(--theme-spacing-sm)"
style={{ bottom: 'var(--theme-spacing-sm)' }}
>
<ActionIcon
icon="arrowLeftS"
iconProps={{ size: 'lg' }}
onClick={handlePreviousPreset}
variant="subtle"
/>
<ActionIcon
icon="arrowRightS"
iconProps={{ size: 'lg' }}
onClick={handleNextPreset}
variant="subtle"
/>
</Group>
<ComponentErrorBoundary>
<VisualizerInner />
</ComponentErrorBoundary>
@@ -0,0 +1,14 @@
import { AnimatePresence } from 'motion/react';
import { FullScreenVisualizer } from '/@/renderer/features/player/components/full-screen-visualizer';
import { useFullScreenPlayerStore } from '/@/renderer/store/full-screen-player.store';
export const FullScreenVisualizerOverlay = () => {
const { visualizerExpanded: isFullScreenVisualizerExpanded } = useFullScreenPlayerStore();
return (
<AnimatePresence initial={false}>
{isFullScreenVisualizerExpanded && <FullScreenVisualizer />}
</AnimatePresence>
);
};
@@ -7,6 +7,7 @@ import { shallow } from 'zustand/shallow';
import styles from './main-content.module.css';
import { FullScreenOverlay } from '/@/renderer/layouts/default-layout/full-screen-overlay';
import { FullScreenVisualizerOverlay } from '/@/renderer/layouts/default-layout/full-screen-visualizer-overlay';
import { LeftSidebar } from '/@/renderer/layouts/default-layout/left-sidebar';
import { RightSidebar } from '/@/renderer/layouts/default-layout/right-sidebar';
import { useAppStore, useAppStoreActions, useSideQueueType } from '/@/renderer/store';
@@ -143,6 +144,7 @@ export const MainContent = ({ shell }: { shell?: boolean }) => {
>
{!shell && (
<>
<FullScreenVisualizerOverlay />
<FullScreenOverlay />
<LeftSidebar isResizing={isResizing} startResizing={startResizing} />
<RightSidebar
@@ -6,6 +6,7 @@ import { Outlet } from 'react-router';
import styles from './mobile-layout.module.css';
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
import { FullScreenVisualizer } from '/@/renderer/features/player/components/full-screen-visualizer';
import { MobileFullscreenPlayer } from '/@/renderer/features/player/components/mobile-fullscreen-player';
import { CommandPalette } from '/@/renderer/features/search/components/command-palette';
import { MobileSidebar } from '/@/renderer/features/sidebar/components/mobile-sidebar';
@@ -30,7 +31,10 @@ interface MobileLayoutProps {
export const MobileLayout = ({ shell }: MobileLayoutProps) => {
const { opened, ...handlers } = useCommandPalette();
const [sidebarOpened, { close: closeSidebar, open: openSidebar }] = useDisclosure(false);
const { expanded: isFullScreenPlayerExpanded } = useFullScreenPlayerStore();
const {
expanded: isFullScreenPlayerExpanded,
visualizerExpanded: isFullScreenVisualizerExpanded,
} = useFullScreenPlayerStore();
const { windowBarStyle } = useWindowSettings();
return (
@@ -82,6 +86,13 @@ export const MobileLayout = ({ shell }: MobileLayoutProps) => {
</div>
)}
</AnimatePresence>
<AnimatePresence initial={false}>
{isFullScreenVisualizerExpanded && (
<div className={styles.fullScreenPlayerOverlay}>
<FullScreenVisualizer />
</div>
)}
</AnimatePresence>
<CommandPalette modalProps={{ handlers, opened }} />
<ContextMenuController.Root />
</>
@@ -17,6 +17,7 @@ interface FullScreenPlayerState {
expanded: boolean;
opacity: number;
useImageAspectRatio: boolean;
visualizerExpanded: boolean;
}
export const useFullScreenPlayerStore = createWithEqualityFn<FullScreenPlayerSlice>()(
@@ -35,6 +36,7 @@ export const useFullScreenPlayerStore = createWithEqualityFn<FullScreenPlayerSli
expanded: false,
opacity: 60,
useImageAspectRatio: false,
visualizerExpanded: false,
})),
{ name: 'store_full_screen_player' },
),
+2
View File
@@ -39,6 +39,7 @@ import {
LuDownload,
LuEllipsis,
LuEllipsisVertical,
LuExpand,
LuExternalLink,
LuFileJson,
LuFlag,
@@ -168,6 +169,7 @@ export const AppIcon = {
emptyPlaylistImage: LuListMusic,
emptySongImage: LuMusic,
error: LuShieldAlert,
expand: LuExpand,
externalLink: LuExternalLink,
favorite: LuHeart,
fileJson: LuFileJson,