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
@@ -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>
);
};