add additional configuration to player sidebar

- allow reordering of panels
- allow separation between lyrics and visualizer panels
- allow resize of panels
This commit is contained in:
jeffvli
2025-12-24 23:20:00 -08:00
parent d822d9cd29
commit 5eb2cff6e9
9 changed files with 376 additions and 64 deletions
+1
View File
@@ -122,6 +122,7 @@
"react-image": "^4.1.0", "react-image": "^4.1.0",
"react-loading-skeleton": "^3.5.0", "react-loading-skeleton": "^3.5.0",
"react-player": "^2.16.0", "react-player": "^2.16.0",
"react-resizable-panels": "^4.0.15",
"react-router": "^7.9.6", "react-router": "^7.9.6",
"react-virtualized-auto-sizer": "^1.0.26", "react-virtualized-auto-sizer": "^1.0.26",
"react-window": "1.8.11", "react-window": "1.8.11",
+14
View File
@@ -191,6 +191,9 @@ importers:
react-player: react-player:
specifier: ^2.16.0 specifier: ^2.16.0
version: 2.16.0(react@19.1.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: react-router:
specifier: ^7.9.6 specifier: ^7.9.6
version: 7.9.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0) version: 7.9.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@@ -4616,6 +4619,12 @@ packages:
'@types/react': '@types/react':
optional: true 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: react-router-dom@7.9.4:
resolution: {integrity: sha512-f30P6bIkmYvnHHa5Gcu65deIXoA2+r3Eb6PJIAddvsT9aGlchMatJ51GgpU470aSqRRbFX22T70yQNUGuW3DfA==} resolution: {integrity: sha512-f30P6bIkmYvnHHa5Gcu65deIXoA2+r3Eb6PJIAddvsT9aGlchMatJ51GgpU470aSqRRbFX22T70yQNUGuW3DfA==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
@@ -10609,6 +10618,11 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 19.2.5 '@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): react-router-dom@7.9.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies: dependencies:
react: 19.1.0 react: 19.1.0
+2
View File
@@ -898,6 +898,8 @@
"showLyricsInSidebar": "show lyrics in player sidebar", "showLyricsInSidebar": "show lyrics in player sidebar",
"showVisualizerInSidebar_description": "a panel will be added to the player sidebar that displays the visualizer", "showVisualizerInSidebar_description": "a panel will be added to the player sidebar that displays the visualizer",
"showVisualizerInSidebar": "show visualizer in player sidebar", "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_description": "preserves pitch when modifying playback speed",
"preservePitch": "preserve pitch", "preservePitch": "preserve pitch",
"audioFadeOnStatusChange": "audio fade on status change", "audioFadeOnStatusChange": "audio fade on status change",
@@ -8,6 +8,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 100%; width: 100%;
pointer-events: none;
opacity: 0; opacity: 0;
transition: opacity 0.2s ease-in-out; transition: opacity 0.2s ease-in-out;
@@ -18,6 +19,20 @@
&:focus-within { &:focus-within {
opacity: 1 !important; 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 { .lyrics-container {
@@ -1,7 +1,9 @@
.play-queue-section { .play-queue-section {
position: relative;
display: flex; display: flex;
flex: 1; flex: 1;
flex-direction: column; flex-direction: column;
height: 100%;
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
} }
@@ -9,10 +11,9 @@
.lyrics-section { .lyrics-section {
position: relative; position: relative;
display: flex; display: flex;
flex: 0 0 300px;
flex-direction: column; flex-direction: column;
min-height: 200px; height: 100%;
max-height: 400px; min-height: 0;
padding: var(--theme-spacing-md); padding: var(--theme-spacing-md);
overflow: hidden; overflow: hidden;
background: var(--theme-colors-background); background: var(--theme-colors-background);
@@ -41,12 +42,38 @@
.visualizer-section { .visualizer-section {
position: relative; position: relative;
display: flex; display: flex;
flex: 0 0 300px;
flex-direction: column; flex-direction: column;
min-height: 200px; height: 100%;
max-height: 400px; min-height: 0;
padding: var(--theme-spacing-md); padding: var(--theme-spacing-md);
overflow: hidden; overflow: hidden;
background: var(--theme-colors-background); background: var(--theme-colors-background);
background-color: var(--theme-colors-background-alternate); 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;
}
@@ -1,5 +1,7 @@
import { useQuery } from '@tanstack/react-query'; 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'; import styles from './sidebar-play-queue.module.css';
@@ -13,12 +15,16 @@ import {
usePlaybackSettings, usePlaybackSettings,
usePlayerSong, usePlayerSong,
useSettingsStore, useSettingsStore,
useSettingsStoreActions,
} from '/@/renderer/store'; } 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 { Flex } from '/@/shared/components/flex/flex';
import { Stack } from '/@/shared/components/stack/stack'; import { Stack } from '/@/shared/components/stack/stack';
import { ItemListKey, PlayerType } from '/@/shared/types/types'; import { ItemListKey, PlayerType } from '/@/shared/types/types';
type SidebarPanelType = 'lyrics' | 'queue' | 'visualizer';
const AudioMotionAnalyzerVisualizer = lazy(() => const AudioMotionAnalyzerVisualizer = lazy(() =>
import('../../visualizer/components/audiomotionanalyzer/visualizer').then((module) => ({ import('../../visualizer/components/audiomotionanalyzer/visualizer').then((module) => ({
default: module.Visualizer, default: module.Visualizer,
@@ -34,6 +40,91 @@ 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 {
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 && <Separator className={styles.resizeHandle} />}
<Panel defaultSize={50} key="queue" minSize={20}>
<div className={styles.playQueueSection}>
<PlayQueue
listKey={ItemListKey.SIDE_QUEUE}
ref={tableRef}
searchTerm={search}
/>
</div>
</Panel>
</>
);
}
if (combinedLyricsAndVisualizer && (panelType === 'lyrics' || panelType === 'visualizer')) {
return (
<>
{index > 0 && <Separator className={styles.resizeHandle} />}
<Panel defaultSize={50} key="combined" minSize={20}>
<CombinedLyricsAndVisualizerPanel />
</Panel>
</>
);
}
if (panelType === 'lyrics') {
return (
<>
{index > 0 && <Separator className={styles.resizeHandle} />}
<Panel defaultSize={totalPanels > 2 ? 25 : 50} key="lyrics" minSize={15}>
<LyricsPanel />
</Panel>
</>
);
}
if (panelType === 'visualizer') {
return (
<>
{index > 0 && <Separator className={styles.resizeHandle} />}
<Panel defaultSize={totalPanels > 2 ? 25 : 50} key="visualizer" minSize={15}>
<VisualizerPanel />
</Panel>
</>
);
}
return null;
};
return ( return (
<Stack gap={0} h="100%" id="sidebar-play-queue-container" pos="relative" w="100%"> <Stack gap={0} h="100%" id="sidebar-play-queue-container" pos="relative" w="100%">
@@ -42,31 +133,174 @@ export const SidebarPlayQueue = () => {
searchTerm={search} searchTerm={search}
type={ItemListKey.SIDE_QUEUE} type={ItemListKey.SIDE_QUEUE}
/> />
<Flex direction="column" style={{ flex: 1, minHeight: 0 }}> {showPanel ? (
<div className={styles.playQueueSection}> <Group orientation="vertical" style={{ flex: 1, minHeight: 0 }}>
<PlayQueue {orderedPanels.map((panel, index) =>
listKey={ItemListKey.SIDE_QUEUE} renderPanel(panel, index, orderedPanels.length),
ref={tableRef} )}
searchTerm={search} </Group>
/> ) : (
</div> <Flex direction="column" style={{ flex: 1, minHeight: 0 }}>
</Flex> <div className={styles.playQueueSection}>
<BottomPanel /> <PlayQueue
listKey={ItemListKey.SIDE_QUEUE}
ref={tableRef}
searchTerm={search}
/>
</div>
</Flex>
)}
</Stack> </Stack>
); );
}; };
const BottomPanel = () => { const PanelReorderControls = ({ panelType }: { panelType: 'lyrics' | 'visualizer' }) => {
const { showLyricsInSidebar, showVisualizerInSidebar } = useGeneralSettings(); const { t } = useTranslation();
const { type, webAudio } = usePlaybackSettings(); 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 (
<div className={styles.panelReorderControls}>
<ActionIconGroup>
<ActionIcon
disabled={!canMoveUp}
icon="arrowUp"
iconProps={{ size: 'sm' }}
onClick={handleMoveUp}
size="xs"
tooltip={{
label: t('action.moveUp', { postProcess: 'sentenceCase' }),
}}
variant="subtle"
/>
<ActionIcon
disabled={!canMoveDown}
icon="arrowDown"
iconProps={{ size: 'sm' }}
onClick={handleMoveDown}
size="xs"
tooltip={{
label: t('action.moveDown', { postProcess: 'sentenceCase' }),
}}
variant="subtle"
/>
<ActionIcon
icon="x"
iconProps={{ size: 'sm' }}
onClick={handleClose}
size="xs"
tooltip={{
label: t('common.close', { postProcess: 'sentenceCase' }),
}}
variant="subtle"
/>
</ActionIconGroup>
</div>
);
};
const LyricsPanel = () => {
return (
<div className={styles.lyricsSection}>
<PanelReorderControls panelType="lyrics" />
<Lyrics fadeOutNoLyricsMessage={false} />
</div>
);
};
const VisualizerPanel = () => {
const visualizerType = useSettingsStore((store) => store.visualizer.type); const visualizerType = useSettingsStore((store) => store.visualizer.type);
return (
<div className={styles.visualizerSection}>
<PanelReorderControls panelType="visualizer" />
<Suspense fallback={<></>}>
{visualizerType === 'butterchurn' ? (
<ButterchurnVisualizer />
) : (
<AudioMotionAnalyzerVisualizer />
)}
</Suspense>
</div>
);
};
const CombinedLyricsAndVisualizerPanel = () => {
const currentSong = usePlayerSong(); const currentSong = usePlayerSong();
const visualizerType = useSettingsStore((store) => store.visualizer.type);
const { data: lyricsData } = useQuery( const { data: lyricsData } = useQuery(
lyricsQueries.songLyrics( lyricsQueries.songLyrics(
{ {
options: { options: {
enabled: showLyricsInSidebar && !!currentSong?.id, enabled: !!currentSong?.id,
}, },
query: { songId: currentSong?.id || '' }, query: { songId: currentSong?.id || '' },
serverId: currentSong?._serverId || '', serverId: currentSong?._serverId || '',
@@ -93,49 +327,24 @@ const BottomPanel = () => {
return false; return false;
}, [lyricsData]); }, [lyricsData]);
const showVisualizer = showVisualizerInSidebar && type === PlayerType.WEB && webAudio;
const showPanel = showLyricsInSidebar || showVisualizer;
if (!showPanel) {
return null;
}
return ( return (
<> <div className={styles.lyricsSection}>
<Divider /> <PanelReorderControls panelType="lyrics" />
{showLyricsInSidebar ? ( <Lyrics fadeOutNoLyricsMessage={true} />
<div className={styles.lyricsSection}> <div
<Lyrics fadeOutNoLyricsMessage={showVisualizer} /> className={styles.visualizerOverlay}
{showVisualizer && ( style={{
<div opacity: hasLyrics ? 0.2 : 1,
className={styles.visualizerOverlay} }}
style={{ >
opacity: hasLyrics ? 0.2 : 1, <Suspense fallback={<></>}>
}} {visualizerType === 'butterchurn' ? (
> <ButterchurnVisualizer />
<Suspense fallback={<></>}> ) : (
{visualizerType === 'butterchurn' ? ( <AudioMotionAnalyzerVisualizer />
<ButterchurnVisualizer />
) : (
<AudioMotionAnalyzerVisualizer />
)}
</Suspense>
</div>
)} )}
</div> </Suspense>
) : ( </div>
showVisualizer && ( </div>
<div className={styles.visualizerSection}>
<Suspense fallback={<></>}>
{visualizerType === 'butterchurn' ? (
<ButterchurnVisualizer />
) : (
<AudioMotionAnalyzerVisualizer />
)}
</Suspense>
</div>
)
)}
</>
); );
}; };
@@ -356,6 +356,23 @@ export const PlayerConfig = () => {
id: 'showVisualizerInSidebar', id: 'showVisualizerInSidebar',
label: t('setting.showVisualizerInSidebar', { postProcess: 'titleCase' }), label: t('setting.showVisualizerInSidebar', { postProcess: 'titleCase' }),
}, },
{
component: (
<Switch
defaultChecked={generalSettings.combinedLyricsAndVisualizer}
onChange={(e) => {
setSettings({
general: {
...generalSettings,
combinedLyricsAndVisualizer: e.currentTarget.checked,
},
});
}}
/>
),
id: 'combinedLyricsAndVisualizer',
label: t('setting.combinedLyricsAndVisualizer', { postProcess: 'titleCase' }),
},
]; ];
return allOptions; return allOptions;
@@ -101,6 +101,27 @@ export const SidebarSettings = () => {
}), }),
title: t('setting.showVisualizerInSidebar', { postProcess: 'sentenceCase' }), title: t('setting.showVisualizerInSidebar', { postProcess: 'sentenceCase' }),
}, },
{
control: (
<Switch
aria-label="Combine lyrics and visualizer"
defaultChecked={settings.combinedLyricsAndVisualizer}
onChange={(e) => {
setSettings({
general: {
...settings,
combinedLyricsAndVisualizer: e.currentTarget.checked,
},
});
}}
/>
),
description: t('setting.combinedLyricsAndVisualizer', {
context: 'description',
postProcess: 'sentenceCase',
}),
title: t('setting.combinedLyricsAndVisualizer', { postProcess: 'sentenceCase' }),
},
]; ];
return ( return (
+6
View File
@@ -103,6 +103,8 @@ const GenreTargetSchema = z.enum(['album', 'track']);
const SideQueueTypeSchema = z.enum(['sideDrawerQueue', 'sideQueue']); const SideQueueTypeSchema = z.enum(['sideDrawerQueue', 'sideQueue']);
const SidebarPanelTypeSchema = z.enum(['queue', 'lyrics', 'visualizer']);
const SidebarItemTypeSchema = z.object({ const SidebarItemTypeSchema = z.object({
disabled: z.boolean(), disabled: z.boolean(),
id: z.string(), id: z.string(),
@@ -346,6 +348,7 @@ export const GeneralSettingsSchema = z.object({
artistItems: z.array(SortableItemSchema(ArtistItemSchema)), artistItems: z.array(SortableItemSchema(ArtistItemSchema)),
artistRadioCount: z.number(), artistRadioCount: z.number(),
buttonSize: z.number(), buttonSize: z.number(),
combinedLyricsAndVisualizer: z.boolean(),
disabledContextMenu: z.record(z.string(), z.boolean()), disabledContextMenu: z.record(z.string(), z.boolean()),
externalLinks: z.boolean(), externalLinks: z.boolean(),
followCurrentSong: z.boolean(), followCurrentSong: z.boolean(),
@@ -375,6 +378,7 @@ export const GeneralSettingsSchema = z.object({
sidebarCollapsedNavigation: z.boolean(), sidebarCollapsedNavigation: z.boolean(),
sidebarCollapseShared: z.boolean(), sidebarCollapseShared: z.boolean(),
sidebarItems: z.array(SidebarItemTypeSchema), sidebarItems: z.array(SidebarItemTypeSchema),
sidebarPanelOrder: z.array(SidebarPanelTypeSchema),
sidebarPlaylistList: z.boolean(), sidebarPlaylistList: z.boolean(),
sideQueueType: SideQueueTypeSchema, sideQueueType: SideQueueTypeSchema,
skipButtons: SkipButtonsSchema, skipButtons: SkipButtonsSchema,
@@ -843,6 +847,7 @@ const initialState: SettingsState = {
artistItems, artistItems,
artistRadioCount: 20, artistRadioCount: 20,
buttonSize: 15, buttonSize: 15,
combinedLyricsAndVisualizer: false,
disabledContextMenu: {}, disabledContextMenu: {},
externalLinks: true, externalLinks: true,
followCurrentSong: true, followCurrentSong: true,
@@ -878,6 +883,7 @@ const initialState: SettingsState = {
sidebarCollapsedNavigation: true, sidebarCollapsedNavigation: true,
sidebarCollapseShared: false, sidebarCollapseShared: false,
sidebarItems, sidebarItems,
sidebarPanelOrder: ['queue', 'lyrics', 'visualizer'],
sidebarPlaylistList: true, sidebarPlaylistList: true,
sideQueueType: 'sideQueue', sideQueueType: 'sideQueue',
skipButtons: { skipButtons: {