mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-09 20:29:36 +02:00
add fullscreen visualizer (#1546)
This commit is contained in:
@@ -46,7 +46,10 @@ const ButterchurnVisualizer = lazy(() =>
|
|||||||
export const SidebarPlayQueue = () => {
|
export const SidebarPlayQueue = () => {
|
||||||
const tableRef = useRef<ItemListHandle | null>(null);
|
const tableRef = useRef<ItemListHandle | null>(null);
|
||||||
const [search, setSearch] = useState<string | undefined>(undefined);
|
const [search, setSearch] = useState<string | undefined>(undefined);
|
||||||
const { expanded: isFullScreenPlayerExpanded } = useFullScreenPlayerStore();
|
const {
|
||||||
|
expanded: isFullScreenPlayerExpanded,
|
||||||
|
visualizerExpanded: isFullScreenVisualizerExpanded,
|
||||||
|
} = useFullScreenPlayerStore();
|
||||||
const [shouldRender, setShouldRender] = useState(!isFullScreenPlayerExpanded);
|
const [shouldRender, setShouldRender] = useState(!isFullScreenPlayerExpanded);
|
||||||
const combinedLyricsAndVisualizer = useCombinedLyricsAndVisualizer();
|
const combinedLyricsAndVisualizer = useCombinedLyricsAndVisualizer();
|
||||||
const showLyricsInSidebar = useShowLyricsInSidebar();
|
const showLyricsInSidebar = useShowLyricsInSidebar();
|
||||||
@@ -60,7 +63,7 @@ export const SidebarPlayQueue = () => {
|
|||||||
const shouldAddTopMargin = isElectron() && windowBarStyle === Platform.WEB;
|
const shouldAddTopMargin = isElectron() && windowBarStyle === Platform.WEB;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isFullScreenPlayerExpanded) {
|
if (isFullScreenPlayerExpanded || isFullScreenVisualizerExpanded) {
|
||||||
// Immediately hide when fullscreen player opens
|
// Immediately hide when fullscreen player opens
|
||||||
setShouldRender(false);
|
setShouldRender(false);
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -74,7 +77,7 @@ export const SidebarPlayQueue = () => {
|
|||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, [isFullScreenPlayerExpanded]);
|
}, [isFullScreenPlayerExpanded, isFullScreenVisualizerExpanded]);
|
||||||
|
|
||||||
const [defaultLayout, onLayoutChange] = usePersistence({
|
const [defaultLayout, onLayoutChange] = usePersistence({
|
||||||
debounce: 300,
|
debounce: 300,
|
||||||
|
|||||||
@@ -237,7 +237,7 @@ const Controls = () => {
|
|||||||
const lyricConfig = { ...lyricsSettings, ...displaySettings };
|
const lyricConfig = { ...lyricsSettings, ...displaySettings };
|
||||||
|
|
||||||
const handleToggleFullScreenPlayer = () => {
|
const handleToggleFullScreenPlayer = () => {
|
||||||
setStore({ expanded: !expanded });
|
setStore({ expanded: !expanded, visualizerExpanded: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLyricsSettings = (property: string, value: any) => {
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+8
-6
@@ -14,19 +14,21 @@
|
|||||||
margin: auto;
|
margin: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
|
||||||
.settings-icon {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.container .settings-icon {
|
.icon-group {
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-group > * {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.2s ease-in-out;
|
transition: opacity 0.2s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.container:hover .icon-group > * {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.visualizer {
|
.visualizer {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-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 { openVisualizerSettingsModal } from '/@/renderer/features/player/utils/open-visualizer-settings-modal';
|
||||||
import { ComponentErrorBoundary } from '/@/renderer/features/shared/components/component-error-boundary';
|
import { ComponentErrorBoundary } from '/@/renderer/features/shared/components/component-error-boundary';
|
||||||
import { useAccent, useSettingsStore } from '/@/renderer/store';
|
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 { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||||
|
import { Group } from '/@/shared/components/group/group';
|
||||||
|
|
||||||
const VisualizerInner = () => {
|
const VisualizerInner = () => {
|
||||||
const { webAudio } = useWebAudio();
|
const { webAudio } = useWebAudio();
|
||||||
@@ -289,18 +294,35 @@ const VisualizerInner = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const Visualizer = () => {
|
export const Visualizer = () => {
|
||||||
|
const { visualizerExpanded } = useFullScreenPlayerStore();
|
||||||
|
const { setStore } = useFullScreenPlayerStoreActions();
|
||||||
|
|
||||||
|
const handleToggleFullscreen = () => {
|
||||||
|
setStore({ expanded: false, visualizerExpanded: !visualizerExpanded });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<ActionIcon
|
<Group
|
||||||
className={styles.settingsIcon}
|
className={styles.iconGroup}
|
||||||
icon="settings2"
|
gap="xs"
|
||||||
iconProps={{ size: 'lg' }}
|
|
||||||
onClick={openVisualizerSettingsModal}
|
|
||||||
pos="absolute"
|
pos="absolute"
|
||||||
right="var(--theme-spacing-sm)"
|
right="var(--theme-spacing-sm)"
|
||||||
top="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>
|
<ComponentErrorBoundary>
|
||||||
<VisualizerInner />
|
<VisualizerInner />
|
||||||
</ComponentErrorBoundary>
|
</ComponentErrorBoundary>
|
||||||
|
|||||||
@@ -6,19 +6,21 @@
|
|||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
background: #000;
|
||||||
&:hover {
|
|
||||||
.settings-icon {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.container .settings-icon {
|
.icon-group {
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-group > * {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.container:hover .icon-group > * {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.canvas {
|
.canvas {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -34,7 +36,7 @@
|
|||||||
z-index: 10;
|
z-index: 10;
|
||||||
padding: var(--theme-spacing-xs) var(--theme-spacing-sm);
|
padding: var(--theme-spacing-xs) var(--theme-spacing-sm);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--theme-colors-foreground);
|
color: #ddd;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
background-color: rgb(0 0 0 / 50%);
|
background-color: rgb(0 0 0 / 50%);
|
||||||
border-radius: 0 var(--theme-radius-md) 0 0;
|
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';
|
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 { openVisualizerSettingsModal } from '/@/renderer/features/player/utils/open-visualizer-settings-modal';
|
||||||
import { ComponentErrorBoundary } from '/@/renderer/features/shared/components/component-error-boundary';
|
import { ComponentErrorBoundary } from '/@/renderer/features/shared/components/component-error-boundary';
|
||||||
import { useSettingsStore, useSettingsStoreActions } from '/@/renderer/store';
|
import { useSettingsStore, useSettingsStoreActions } from '/@/renderer/store';
|
||||||
|
import {
|
||||||
|
useFullScreenPlayerStore,
|
||||||
|
useFullScreenPlayerStoreActions,
|
||||||
|
} from '/@/renderer/store/full-screen-player.store';
|
||||||
import { usePlayerStatus } from '/@/renderer/store/player.store';
|
import { usePlayerStatus } from '/@/renderer/store/player.store';
|
||||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||||
|
import { Group } from '/@/shared/components/group/group';
|
||||||
import { Text } from '/@/shared/components/text/text';
|
import { Text } from '/@/shared/components/text/text';
|
||||||
import { PlayerStatus } from '/@/shared/types/types';
|
import { PlayerStatus } from '/@/shared/types/types';
|
||||||
|
|
||||||
@@ -473,18 +478,147 @@ const VisualizerInner = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const Visualizer = () => {
|
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 (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<ActionIcon
|
<Group
|
||||||
className={styles.settingsIcon}
|
className={styles.iconGroup}
|
||||||
icon="settings2"
|
gap="xs"
|
||||||
iconProps={{ size: 'lg' }}
|
|
||||||
onClick={openVisualizerSettingsModal}
|
|
||||||
pos="absolute"
|
pos="absolute"
|
||||||
right="var(--theme-spacing-sm)"
|
right="var(--theme-spacing-sm)"
|
||||||
top="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>
|
<ComponentErrorBoundary>
|
||||||
<VisualizerInner />
|
<VisualizerInner />
|
||||||
</ComponentErrorBoundary>
|
</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 styles from './main-content.module.css';
|
||||||
|
|
||||||
import { FullScreenOverlay } from '/@/renderer/layouts/default-layout/full-screen-overlay';
|
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 { LeftSidebar } from '/@/renderer/layouts/default-layout/left-sidebar';
|
||||||
import { RightSidebar } from '/@/renderer/layouts/default-layout/right-sidebar';
|
import { RightSidebar } from '/@/renderer/layouts/default-layout/right-sidebar';
|
||||||
import { useAppStore, useAppStoreActions, useSideQueueType } from '/@/renderer/store';
|
import { useAppStore, useAppStoreActions, useSideQueueType } from '/@/renderer/store';
|
||||||
@@ -143,6 +144,7 @@ export const MainContent = ({ shell }: { shell?: boolean }) => {
|
|||||||
>
|
>
|
||||||
{!shell && (
|
{!shell && (
|
||||||
<>
|
<>
|
||||||
|
<FullScreenVisualizerOverlay />
|
||||||
<FullScreenOverlay />
|
<FullScreenOverlay />
|
||||||
<LeftSidebar isResizing={isResizing} startResizing={startResizing} />
|
<LeftSidebar isResizing={isResizing} startResizing={startResizing} />
|
||||||
<RightSidebar
|
<RightSidebar
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Outlet } from 'react-router';
|
|||||||
import styles from './mobile-layout.module.css';
|
import styles from './mobile-layout.module.css';
|
||||||
|
|
||||||
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
|
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 { MobileFullscreenPlayer } from '/@/renderer/features/player/components/mobile-fullscreen-player';
|
||||||
import { CommandPalette } from '/@/renderer/features/search/components/command-palette';
|
import { CommandPalette } from '/@/renderer/features/search/components/command-palette';
|
||||||
import { MobileSidebar } from '/@/renderer/features/sidebar/components/mobile-sidebar';
|
import { MobileSidebar } from '/@/renderer/features/sidebar/components/mobile-sidebar';
|
||||||
@@ -30,7 +31,10 @@ interface MobileLayoutProps {
|
|||||||
export const MobileLayout = ({ shell }: MobileLayoutProps) => {
|
export const MobileLayout = ({ shell }: MobileLayoutProps) => {
|
||||||
const { opened, ...handlers } = useCommandPalette();
|
const { opened, ...handlers } = useCommandPalette();
|
||||||
const [sidebarOpened, { close: closeSidebar, open: openSidebar }] = useDisclosure(false);
|
const [sidebarOpened, { close: closeSidebar, open: openSidebar }] = useDisclosure(false);
|
||||||
const { expanded: isFullScreenPlayerExpanded } = useFullScreenPlayerStore();
|
const {
|
||||||
|
expanded: isFullScreenPlayerExpanded,
|
||||||
|
visualizerExpanded: isFullScreenVisualizerExpanded,
|
||||||
|
} = useFullScreenPlayerStore();
|
||||||
const { windowBarStyle } = useWindowSettings();
|
const { windowBarStyle } = useWindowSettings();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -82,6 +86,13 @@ export const MobileLayout = ({ shell }: MobileLayoutProps) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{isFullScreenVisualizerExpanded && (
|
||||||
|
<div className={styles.fullScreenPlayerOverlay}>
|
||||||
|
<FullScreenVisualizer />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
<CommandPalette modalProps={{ handlers, opened }} />
|
<CommandPalette modalProps={{ handlers, opened }} />
|
||||||
<ContextMenuController.Root />
|
<ContextMenuController.Root />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ interface FullScreenPlayerState {
|
|||||||
expanded: boolean;
|
expanded: boolean;
|
||||||
opacity: number;
|
opacity: number;
|
||||||
useImageAspectRatio: boolean;
|
useImageAspectRatio: boolean;
|
||||||
|
visualizerExpanded: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useFullScreenPlayerStore = createWithEqualityFn<FullScreenPlayerSlice>()(
|
export const useFullScreenPlayerStore = createWithEqualityFn<FullScreenPlayerSlice>()(
|
||||||
@@ -35,6 +36,7 @@ export const useFullScreenPlayerStore = createWithEqualityFn<FullScreenPlayerSli
|
|||||||
expanded: false,
|
expanded: false,
|
||||||
opacity: 60,
|
opacity: 60,
|
||||||
useImageAspectRatio: false,
|
useImageAspectRatio: false,
|
||||||
|
visualizerExpanded: false,
|
||||||
})),
|
})),
|
||||||
{ name: 'store_full_screen_player' },
|
{ name: 'store_full_screen_player' },
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
LuDownload,
|
LuDownload,
|
||||||
LuEllipsis,
|
LuEllipsis,
|
||||||
LuEllipsisVertical,
|
LuEllipsisVertical,
|
||||||
|
LuExpand,
|
||||||
LuExternalLink,
|
LuExternalLink,
|
||||||
LuFileJson,
|
LuFileJson,
|
||||||
LuFlag,
|
LuFlag,
|
||||||
@@ -168,6 +169,7 @@ export const AppIcon = {
|
|||||||
emptyPlaylistImage: LuListMusic,
|
emptyPlaylistImage: LuListMusic,
|
||||||
emptySongImage: LuMusic,
|
emptySongImage: LuMusic,
|
||||||
error: LuShieldAlert,
|
error: LuShieldAlert,
|
||||||
|
expand: LuExpand,
|
||||||
externalLink: LuExternalLink,
|
externalLink: LuExternalLink,
|
||||||
favorite: LuHeart,
|
favorite: LuHeart,
|
||||||
fileJson: LuFileJson,
|
fileJson: LuFileJson,
|
||||||
|
|||||||
Reference in New Issue
Block a user