diff --git a/src/renderer/features/now-playing/components/sidebar-play-queue.tsx b/src/renderer/features/now-playing/components/sidebar-play-queue.tsx index 2df00405f..12509e5f4 100644 --- a/src/renderer/features/now-playing/components/sidebar-play-queue.tsx +++ b/src/renderer/features/now-playing/components/sidebar-play-queue.tsx @@ -46,7 +46,10 @@ const ButterchurnVisualizer = lazy(() => export const SidebarPlayQueue = () => { const tableRef = useRef(null); const [search, setSearch] = useState(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, diff --git a/src/renderer/features/player/components/full-screen-player.tsx b/src/renderer/features/player/components/full-screen-player.tsx index e4864b862..7226927d3 100644 --- a/src/renderer/features/player/components/full-screen-player.tsx +++ b/src/renderer/features/player/components/full-screen-player.tsx @@ -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) => { diff --git a/src/renderer/features/player/components/full-screen-visualizer-song-info.tsx b/src/renderer/features/player/components/full-screen-visualizer-song-info.tsx new file mode 100644 index 000000000..9fe71b716 --- /dev/null +++ b/src/renderer/features/player/components/full-screen-visualizer-song-info.tsx @@ -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(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 ( + <> + + + + + {currentSong.name} + + {currentSong.artistName && ( + + {currentSong.artistName} + + )} + + + + ); +}; diff --git a/src/renderer/features/player/components/full-screen-visualizer.module.css b/src/renderer/features/player/components/full-screen-visualizer.module.css new file mode 100644 index 000000000..901c3bf4b --- /dev/null +++ b/src/renderer/features/player/components/full-screen-visualizer.module.css @@ -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; +} diff --git a/src/renderer/features/player/components/full-screen-visualizer.tsx b/src/renderer/features/player/components/full-screen-visualizer.tsx new file mode 100644 index 000000000..8dd7a7158 --- /dev/null +++ b/src/renderer/features/player/components/full-screen-visualizer.tsx @@ -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 ( + + {children} + + ); + }, +); + +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(null); + + const handleCloseVisualizer = () => { + setStore({ visualizerExpanded: false }); + }; + + useHotkeys([['Escape', handleCloseVisualizer]]); + + useLayoutEffect(() => { + if (isOpenedRef.current !== null) { + setStore({ visualizerExpanded: false }); + } + + isOpenedRef.current = true; + }, [location, setStore]); + + return ( + +
+ {type === PlayerType.WEB && webAudio ? ( + }> + {visualizerType === 'butterchurn' ? ( + + ) : ( + + )} + + ) : null} + +
+
+ ); +}; diff --git a/src/renderer/features/visualizer/components/audiomotionanalyzer/visualizer.module.css b/src/renderer/features/visualizer/components/audiomotionanalyzer/visualizer.module.css index b918e038f..0beec3c0d 100644 --- a/src/renderer/features/visualizer/components/audiomotionanalyzer/visualizer.module.css +++ b/src/renderer/features/visualizer/components/audiomotionanalyzer/visualizer.module.css @@ -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%; diff --git a/src/renderer/features/visualizer/components/audiomotionanalyzer/visualizer.tsx b/src/renderer/features/visualizer/components/audiomotionanalyzer/visualizer.tsx index f012080b8..693d771b5 100644 --- a/src/renderer/features/visualizer/components/audiomotionanalyzer/visualizer.tsx +++ b/src/renderer/features/visualizer/components/audiomotionanalyzer/visualizer.tsx @@ -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 (
- + > + + + diff --git a/src/renderer/features/visualizer/components/butternchurn/visualizer.module.css b/src/renderer/features/visualizer/components/butternchurn/visualizer.module.css index 0403e9a99..aa68aa3a3 100644 --- a/src/renderer/features/visualizer/components/butternchurn/visualizer.module.css +++ b/src/renderer/features/visualizer/components/butternchurn/visualizer.module.css @@ -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; diff --git a/src/renderer/features/visualizer/components/butternchurn/visualizer.tsx b/src/renderer/features/visualizer/components/butternchurn/visualizer.tsx index d336a76f1..5d0be31a6 100644 --- a/src/renderer/features/visualizer/components/butternchurn/visualizer.tsx +++ b/src/renderer/features/visualizer/components/butternchurn/visualizer.tsx @@ -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(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 (
- + > + + + + + + + diff --git a/src/renderer/layouts/default-layout/full-screen-visualizer-overlay.tsx b/src/renderer/layouts/default-layout/full-screen-visualizer-overlay.tsx new file mode 100644 index 000000000..9476e06b7 --- /dev/null +++ b/src/renderer/layouts/default-layout/full-screen-visualizer-overlay.tsx @@ -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 ( + + {isFullScreenVisualizerExpanded && } + + ); +}; diff --git a/src/renderer/layouts/default-layout/main-content.tsx b/src/renderer/layouts/default-layout/main-content.tsx index cc51b8124..e0598acbd 100644 --- a/src/renderer/layouts/default-layout/main-content.tsx +++ b/src/renderer/layouts/default-layout/main-content.tsx @@ -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 && ( <> + { 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) => {
)} + + {isFullScreenVisualizerExpanded && ( +
+ +
+ )} +
diff --git a/src/renderer/store/full-screen-player.store.ts b/src/renderer/store/full-screen-player.store.ts index a482a802b..1a1e303b8 100644 --- a/src/renderer/store/full-screen-player.store.ts +++ b/src/renderer/store/full-screen-player.store.ts @@ -17,6 +17,7 @@ interface FullScreenPlayerState { expanded: boolean; opacity: number; useImageAspectRatio: boolean; + visualizerExpanded: boolean; } export const useFullScreenPlayerStore = createWithEqualityFn()( @@ -35,6 +36,7 @@ export const useFullScreenPlayerStore = createWithEqualityFn