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-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",
+14
View File
@@ -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
+2
View File
@@ -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",
@@ -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 {
@@ -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;
}
@@ -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<ItemListHandle | null>(null);
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 (
<Stack gap={0} h="100%" id="sidebar-play-queue-container" pos="relative" w="100%">
@@ -42,31 +133,174 @@ export const SidebarPlayQueue = () => {
searchTerm={search}
type={ItemListKey.SIDE_QUEUE}
/>
<Flex direction="column" style={{ flex: 1, minHeight: 0 }}>
<div className={styles.playQueueSection}>
<PlayQueue
listKey={ItemListKey.SIDE_QUEUE}
ref={tableRef}
searchTerm={search}
/>
</div>
</Flex>
<BottomPanel />
{showPanel ? (
<Group orientation="vertical" style={{ flex: 1, minHeight: 0 }}>
{orderedPanels.map((panel, index) =>
renderPanel(panel, index, orderedPanels.length),
)}
</Group>
) : (
<Flex direction="column" style={{ flex: 1, minHeight: 0 }}>
<div className={styles.playQueueSection}>
<PlayQueue
listKey={ItemListKey.SIDE_QUEUE}
ref={tableRef}
searchTerm={search}
/>
</div>
</Flex>
)}
</Stack>
);
};
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 (
<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);
return (
<div className={styles.visualizerSection}>
<PanelReorderControls panelType="visualizer" />
<Suspense fallback={<></>}>
{visualizerType === 'butterchurn' ? (
<ButterchurnVisualizer />
) : (
<AudioMotionAnalyzerVisualizer />
)}
</Suspense>
</div>
);
};
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 (
<>
<Divider />
{showLyricsInSidebar ? (
<div className={styles.lyricsSection}>
<Lyrics fadeOutNoLyricsMessage={showVisualizer} />
{showVisualizer && (
<div
className={styles.visualizerOverlay}
style={{
opacity: hasLyrics ? 0.2 : 1,
}}
>
<Suspense fallback={<></>}>
{visualizerType === 'butterchurn' ? (
<ButterchurnVisualizer />
) : (
<AudioMotionAnalyzerVisualizer />
)}
</Suspense>
</div>
<div className={styles.lyricsSection}>
<PanelReorderControls panelType="lyrics" />
<Lyrics fadeOutNoLyricsMessage={true} />
<div
className={styles.visualizerOverlay}
style={{
opacity: hasLyrics ? 0.2 : 1,
}}
>
<Suspense fallback={<></>}>
{visualizerType === 'butterchurn' ? (
<ButterchurnVisualizer />
) : (
<AudioMotionAnalyzerVisualizer />
)}
</div>
) : (
showVisualizer && (
<div className={styles.visualizerSection}>
<Suspense fallback={<></>}>
{visualizerType === 'butterchurn' ? (
<ButterchurnVisualizer />
) : (
<AudioMotionAnalyzerVisualizer />
)}
</Suspense>
</div>
)
)}
</>
</Suspense>
</div>
</div>
);
};
@@ -356,6 +356,23 @@ export const PlayerConfig = () => {
id: 'showVisualizerInSidebar',
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;
@@ -101,6 +101,27 @@ export const SidebarSettings = () => {
}),
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 (
+6
View File
@@ -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: {