From 5eb2cff6e927667024e47d08bb079fbc5deed9b5 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Wed, 24 Dec 2025 23:20:00 -0800 Subject: [PATCH] add additional configuration to player sidebar - allow reordering of panels - allow separation between lyrics and visualizer panels - allow resize of panels --- package.json | 1 + pnpm-lock.yaml | 14 + src/i18n/locales/en.json | 2 + .../features/lyrics/lyrics.module.css | 15 + .../components/sidebar-play-queue.module.css | 39 ++- .../components/sidebar-play-queue.tsx | 325 ++++++++++++++---- .../player/components/player-config.tsx | 17 + .../components/general/sidebar-settings.tsx | 21 ++ src/renderer/store/settings.store.ts | 6 + 9 files changed, 376 insertions(+), 64 deletions(-) diff --git a/package.json b/package.json index ada6db758..64834bca5 100644 --- a/package.json +++ b/package.json @@ -122,6 +122,7 @@ "react-image": "^4.1.0", "react-loading-skeleton": "^3.5.0", "react-player": "^2.16.0", + "react-resizable-panels": "^4.0.15", "react-router": "^7.9.6", "react-virtualized-auto-sizer": "^1.0.26", "react-window": "1.8.11", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c9025b34f..f87608195 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -191,6 +191,9 @@ importers: react-player: specifier: ^2.16.0 version: 2.16.0(react@19.1.0) + react-resizable-panels: + specifier: ^4.0.15 + version: 4.0.15(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react-router: specifier: ^7.9.6 version: 7.9.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -4616,6 +4619,12 @@ packages: '@types/react': optional: true + react-resizable-panels@4.0.15: + resolution: {integrity: sha512-+ygM/EI2h4Qc/cl2fasQ2qwOgNfpQwXLNTU5PqhhPerliX+wnbf7ejcqran7lz3BqABzjddf0pJ3j3G/+A0v9Q==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + react-router-dom@7.9.4: resolution: {integrity: sha512-f30P6bIkmYvnHHa5Gcu65deIXoA2+r3Eb6PJIAddvsT9aGlchMatJ51GgpU470aSqRRbFX22T70yQNUGuW3DfA==} engines: {node: '>=20.0.0'} @@ -10609,6 +10618,11 @@ snapshots: optionalDependencies: '@types/react': 19.2.5 + react-resizable-panels@4.0.15(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + react-router-dom@7.9.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: react: 19.1.0 diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 9bfa103e2..39bd9159c 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -898,6 +898,8 @@ "showLyricsInSidebar": "show lyrics in player sidebar", "showVisualizerInSidebar_description": "a panel will be added to the player sidebar that displays the visualizer", "showVisualizerInSidebar": "show visualizer in player sidebar", + "combinedLyricsAndVisualizer_description": "combine lyrics and visualizer into the same panel", + "combinedLyricsAndVisualizer": "combine lyrics and visualizer in player sidebar", "preservePitch_description": "preserves pitch when modifying playback speed", "preservePitch": "preserve pitch", "audioFadeOnStatusChange": "audio fade on status change", diff --git a/src/renderer/features/lyrics/lyrics.module.css b/src/renderer/features/lyrics/lyrics.module.css index a270ea688..a073374dd 100644 --- a/src/renderer/features/lyrics/lyrics.module.css +++ b/src/renderer/features/lyrics/lyrics.module.css @@ -8,6 +8,7 @@ align-items: center; justify-content: center; width: 100%; + pointer-events: none; opacity: 0; transition: opacity 0.2s ease-in-out; @@ -18,6 +19,20 @@ &:focus-within { opacity: 1 !important; } + + :global(> *), + :global(> * > *), + :global(div) { + pointer-events: none; + } + + :global(button), + :global(input), + :global([role='button']), + :global([role='combobox']), + :global([role='textbox']) { + pointer-events: auto; + } } .lyrics-container { diff --git a/src/renderer/features/now-playing/components/sidebar-play-queue.module.css b/src/renderer/features/now-playing/components/sidebar-play-queue.module.css index 7075476a1..dac5efc25 100644 --- a/src/renderer/features/now-playing/components/sidebar-play-queue.module.css +++ b/src/renderer/features/now-playing/components/sidebar-play-queue.module.css @@ -1,7 +1,9 @@ .play-queue-section { + position: relative; display: flex; flex: 1; flex-direction: column; + height: 100%; min-height: 0; overflow: hidden; } @@ -9,10 +11,9 @@ .lyrics-section { position: relative; display: flex; - flex: 0 0 300px; flex-direction: column; - min-height: 200px; - max-height: 400px; + height: 100%; + min-height: 0; padding: var(--theme-spacing-md); overflow: hidden; background: var(--theme-colors-background); @@ -41,12 +42,38 @@ .visualizer-section { position: relative; display: flex; - flex: 0 0 300px; flex-direction: column; - min-height: 200px; - max-height: 400px; + height: 100%; + min-height: 0; padding: var(--theme-spacing-md); overflow: hidden; background: var(--theme-colors-background); background-color: var(--theme-colors-background-alternate); } + +.resize-handle { + position: relative; + flex-shrink: 0; + width: 100%; + height: 1px; + cursor: row-resize; + background-color: var(--theme-colors-border); + transition: + background-color 0.2s ease, + height 0.2s ease; +} + +.panel-reorder-controls { + position: absolute; + top: var(--theme-spacing-md); + left: var(--theme-spacing-md); + z-index: 100; + pointer-events: auto; + opacity: 0; + transition: opacity 0.2s ease; +} + +.lyrics-section:hover .panel-reorder-controls, +.visualizer-section:hover .panel-reorder-controls { + opacity: 1; +} 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 14d76eef2..29c526a0d 100644 --- a/src/renderer/features/now-playing/components/sidebar-play-queue.tsx +++ b/src/renderer/features/now-playing/components/sidebar-play-queue.tsx @@ -1,5 +1,7 @@ import { useQuery } from '@tanstack/react-query'; -import { lazy, Suspense, useMemo, useRef, useState } from 'react'; +import { lazy, Suspense, useCallback, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Group, Panel, Separator } from 'react-resizable-panels'; import styles from './sidebar-play-queue.module.css'; @@ -13,12 +15,16 @@ import { usePlaybackSettings, usePlayerSong, useSettingsStore, + useSettingsStoreActions, } from '/@/renderer/store'; -import { Divider } from '/@/shared/components/divider/divider'; +import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; +import { ActionIconGroup } from '/@/shared/components/action-icon/action-icon'; import { Flex } from '/@/shared/components/flex/flex'; import { Stack } from '/@/shared/components/stack/stack'; import { ItemListKey, PlayerType } from '/@/shared/types/types'; +type SidebarPanelType = 'lyrics' | 'queue' | 'visualizer'; + const AudioMotionAnalyzerVisualizer = lazy(() => import('../../visualizer/components/audiomotionanalyzer/visualizer').then((module) => ({ default: module.Visualizer, @@ -34,6 +40,91 @@ const ButterchurnVisualizer = lazy(() => export const SidebarPlayQueue = () => { const tableRef = useRef(null); const [search, setSearch] = useState(undefined); + const { + combinedLyricsAndVisualizer, + showLyricsInSidebar, + showVisualizerInSidebar, + sidebarPanelOrder, + } = useGeneralSettings(); + const { type, webAudio } = usePlaybackSettings(); + const showVisualizer = showVisualizerInSidebar && type === PlayerType.WEB && webAudio; + const showPanel = showLyricsInSidebar || showVisualizer; + + // Filter and order panels based on what's enabled + const orderedPanels = useMemo(() => { + if (combinedLyricsAndVisualizer) { + // When combined, use the order from settings but filter to only show queue and lyrics (combined) + const visiblePanels = sidebarPanelOrder.filter((panel) => { + if (panel === 'queue') return true; + if (panel === 'lyrics') return showLyricsInSidebar || showVisualizer; + return false; + }); + return visiblePanels; + } + + const visiblePanels = sidebarPanelOrder.filter((panel) => { + if (panel === 'queue') return true; + if (panel === 'lyrics') return showLyricsInSidebar; + if (panel === 'visualizer') return showVisualizer; + return false; + }); + + return visiblePanels; + }, [combinedLyricsAndVisualizer, showLyricsInSidebar, showVisualizer, sidebarPanelOrder]); + + const renderPanel = (panelType: SidebarPanelType, index: number, totalPanels: number) => { + if (panelType === 'queue') { + return ( + <> + {index > 0 && } + +
+ +
+
+ + ); + } + + if (combinedLyricsAndVisualizer && (panelType === 'lyrics' || panelType === 'visualizer')) { + return ( + <> + {index > 0 && } + + + + + ); + } + + if (panelType === 'lyrics') { + return ( + <> + {index > 0 && } + 2 ? 25 : 50} key="lyrics" minSize={15}> + + + + ); + } + + if (panelType === 'visualizer') { + return ( + <> + {index > 0 && } + 2 ? 25 : 50} key="visualizer" minSize={15}> + + + + ); + } + + return null; + }; return ( @@ -42,31 +133,174 @@ export const SidebarPlayQueue = () => { searchTerm={search} type={ItemListKey.SIDE_QUEUE} /> - -
- -
-
- + {showPanel ? ( + + {orderedPanels.map((panel, index) => + renderPanel(panel, index, orderedPanels.length), + )} + + ) : ( + +
+ +
+
+ )}
); }; -const BottomPanel = () => { - const { showLyricsInSidebar, showVisualizerInSidebar } = useGeneralSettings(); - const { type, webAudio } = usePlaybackSettings(); +const PanelReorderControls = ({ panelType }: { panelType: 'lyrics' | 'visualizer' }) => { + const { t } = useTranslation(); + const generalSettings = useGeneralSettings(); + const { combinedLyricsAndVisualizer, sidebarPanelOrder } = generalSettings; + const { setSettings } = useSettingsStoreActions(); + + const currentIndex = sidebarPanelOrder.indexOf(panelType); + const canMoveUp = currentIndex > 0; + const canMoveDown = currentIndex < sidebarPanelOrder.length - 1; + + const handleMoveUp = useCallback(() => { + if (!canMoveUp) return; + + const newOrder = [...sidebarPanelOrder]; + const targetIndex = currentIndex - 1; + + [newOrder[currentIndex], newOrder[targetIndex]] = [ + newOrder[targetIndex], + newOrder[currentIndex], + ]; + + setSettings({ + general: { + ...generalSettings, + sidebarPanelOrder: newOrder, + }, + }); + }, [canMoveUp, currentIndex, generalSettings, sidebarPanelOrder, setSettings]); + + const handleMoveDown = useCallback(() => { + if (!canMoveDown) return; + + const newOrder = [...sidebarPanelOrder]; + [newOrder[currentIndex], newOrder[currentIndex + 1]] = [ + newOrder[currentIndex + 1], + newOrder[currentIndex], + ]; + + setSettings({ + general: { + ...generalSettings, + sidebarPanelOrder: newOrder, + }, + }); + }, [canMoveDown, currentIndex, generalSettings, sidebarPanelOrder, setSettings]); + + const handleClose = useCallback(() => { + if (combinedLyricsAndVisualizer && panelType === 'lyrics') { + setSettings({ + general: { + ...generalSettings, + showLyricsInSidebar: false, + showVisualizerInSidebar: false, + }, + }); + } else if (panelType === 'lyrics') { + setSettings({ + general: { + ...generalSettings, + showLyricsInSidebar: false, + }, + }); + } else if (panelType === 'visualizer') { + setSettings({ + general: { + ...generalSettings, + showVisualizerInSidebar: false, + }, + }); + } + }, [combinedLyricsAndVisualizer, generalSettings, panelType, setSettings]); + + return ( +
+ + + + + +
+ ); +}; + +const LyricsPanel = () => { + return ( +
+ + +
+ ); +}; + +const VisualizerPanel = () => { const visualizerType = useSettingsStore((store) => store.visualizer.type); + + return ( +
+ + }> + {visualizerType === 'butterchurn' ? ( + + ) : ( + + )} + +
+ ); +}; + +const CombinedLyricsAndVisualizerPanel = () => { const currentSong = usePlayerSong(); + const visualizerType = useSettingsStore((store) => store.visualizer.type); const { data: lyricsData } = useQuery( lyricsQueries.songLyrics( { options: { - enabled: showLyricsInSidebar && !!currentSong?.id, + enabled: !!currentSong?.id, }, query: { songId: currentSong?.id || '' }, serverId: currentSong?._serverId || '', @@ -93,49 +327,24 @@ const BottomPanel = () => { return false; }, [lyricsData]); - const showVisualizer = showVisualizerInSidebar && type === PlayerType.WEB && webAudio; - const showPanel = showLyricsInSidebar || showVisualizer; - - if (!showPanel) { - return null; - } - return ( - <> - - {showLyricsInSidebar ? ( -
- - {showVisualizer && ( -
- }> - {visualizerType === 'butterchurn' ? ( - - ) : ( - - )} - -
+
+ + +
+ }> + {visualizerType === 'butterchurn' ? ( + + ) : ( + )} -
- ) : ( - showVisualizer && ( -
- }> - {visualizerType === 'butterchurn' ? ( - - ) : ( - - )} - -
- ) - )} - + +
+
); }; diff --git a/src/renderer/features/player/components/player-config.tsx b/src/renderer/features/player/components/player-config.tsx index bce7b1e3e..35f6f690b 100644 --- a/src/renderer/features/player/components/player-config.tsx +++ b/src/renderer/features/player/components/player-config.tsx @@ -356,6 +356,23 @@ export const PlayerConfig = () => { id: 'showVisualizerInSidebar', label: t('setting.showVisualizerInSidebar', { postProcess: 'titleCase' }), }, + { + component: ( + { + setSettings({ + general: { + ...generalSettings, + combinedLyricsAndVisualizer: e.currentTarget.checked, + }, + }); + }} + /> + ), + id: 'combinedLyricsAndVisualizer', + label: t('setting.combinedLyricsAndVisualizer', { postProcess: 'titleCase' }), + }, ]; return allOptions; diff --git a/src/renderer/features/settings/components/general/sidebar-settings.tsx b/src/renderer/features/settings/components/general/sidebar-settings.tsx index 5f86aec39..52fff9757 100644 --- a/src/renderer/features/settings/components/general/sidebar-settings.tsx +++ b/src/renderer/features/settings/components/general/sidebar-settings.tsx @@ -101,6 +101,27 @@ export const SidebarSettings = () => { }), title: t('setting.showVisualizerInSidebar', { postProcess: 'sentenceCase' }), }, + { + control: ( + { + setSettings({ + general: { + ...settings, + combinedLyricsAndVisualizer: e.currentTarget.checked, + }, + }); + }} + /> + ), + description: t('setting.combinedLyricsAndVisualizer', { + context: 'description', + postProcess: 'sentenceCase', + }), + title: t('setting.combinedLyricsAndVisualizer', { postProcess: 'sentenceCase' }), + }, ]; return ( diff --git a/src/renderer/store/settings.store.ts b/src/renderer/store/settings.store.ts index 258cc1158..37b68baeb 100644 --- a/src/renderer/store/settings.store.ts +++ b/src/renderer/store/settings.store.ts @@ -103,6 +103,8 @@ const GenreTargetSchema = z.enum(['album', 'track']); const SideQueueTypeSchema = z.enum(['sideDrawerQueue', 'sideQueue']); +const SidebarPanelTypeSchema = z.enum(['queue', 'lyrics', 'visualizer']); + const SidebarItemTypeSchema = z.object({ disabled: z.boolean(), id: z.string(), @@ -346,6 +348,7 @@ export const GeneralSettingsSchema = z.object({ artistItems: z.array(SortableItemSchema(ArtistItemSchema)), artistRadioCount: z.number(), buttonSize: z.number(), + combinedLyricsAndVisualizer: z.boolean(), disabledContextMenu: z.record(z.string(), z.boolean()), externalLinks: z.boolean(), followCurrentSong: z.boolean(), @@ -375,6 +378,7 @@ export const GeneralSettingsSchema = z.object({ sidebarCollapsedNavigation: z.boolean(), sidebarCollapseShared: z.boolean(), sidebarItems: z.array(SidebarItemTypeSchema), + sidebarPanelOrder: z.array(SidebarPanelTypeSchema), sidebarPlaylistList: z.boolean(), sideQueueType: SideQueueTypeSchema, skipButtons: SkipButtonsSchema, @@ -843,6 +847,7 @@ const initialState: SettingsState = { artistItems, artistRadioCount: 20, buttonSize: 15, + combinedLyricsAndVisualizer: false, disabledContextMenu: {}, externalLinks: true, followCurrentSong: true, @@ -878,6 +883,7 @@ const initialState: SettingsState = { sidebarCollapsedNavigation: true, sidebarCollapseShared: false, sidebarItems, + sidebarPanelOrder: ['queue', 'lyrics', 'visualizer'], sidebarPlaylistList: true, sideQueueType: 'sideQueue', skipButtons: {