From c39465dfad6bc4aa2252b7638efa9b1a67717033 Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Fri, 5 Jun 2026 12:35:39 +0200 Subject: [PATCH] feat(sidebar): toggle to pin Now Playing to the top (#1000) * feat(sidebar): add toggle to pin Now Playing to the top The fixed Now Playing entry can now be pinned to the very top of the sidebar (above the Library label) instead of sitting above the bottom spacer. Adds a persisted `nowPlayingAtTop` setting (default off, so existing layouts are unchanged) and a toggle in the sidebar customizer next to the Mix-navigation split. The entry stays non-hideable and keeps its playing indicator in both positions. * i18n(settings): Now Playing-at-top toggle strings (9 locales) * docs(changelog): note Now Playing top toggle (#1000) --- CHANGELOG.md | 10 ++++++ src/components/Sidebar.tsx | 2 ++ src/components/settings/SidebarCustomizer.tsx | 16 +++++++++ src/components/sidebar/SidebarNavBody.tsx | 36 +++++++++++-------- src/locales/de/settings.ts | 2 ++ src/locales/en/settings.ts | 2 ++ src/locales/es/settings.ts | 2 ++ src/locales/fr/settings.ts | 2 ++ src/locales/nb/settings.ts | 2 ++ src/locales/nl/settings.ts | 2 ++ src/locales/ro/settings.ts | 2 ++ src/locales/ru/settings.ts | 2 ++ src/locales/zh/settings.ts | 2 ++ src/store/authDiscoveryActions.ts | 2 ++ src/store/authStore.ts | 1 + src/store/authStoreTypes.ts | 4 +++ 16 files changed, 75 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5654e296..586580e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.48.0] +## Added + +### Sidebar — pin Now Playing to the top + +**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1000](https://github.com/Psychotoxical/psysonic/pull/1000), suggested by [@PHLAK](https://github.com/PHLAK)** + +* New **Settings → Sidebar** toggle moves the "Now Playing" entry to the top of the sidebar instead of the bottom (off by default). + + + ## Changed ### Dependencies — npm and Rust refresh diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index bfa3e42d..da36c8c1 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -65,6 +65,7 @@ export default function Sidebar({ const sidebarItems = useSidebarStore(s => s.items); const setSidebarItems = useSidebarStore(s => s.setItems); const randomNavMode = useAuthStore(s => s.randomNavMode); + const nowPlayingAtTop = useAuthStore(s => s.nowPlayingAtTop); const luckyMixBase = useLuckyMixAvailable(); // Sidebar surfaces Lucky Mix as its own entry only in "separate" nav mode — // in hub mode it lives inside the Build-a-Mix landing page instead. @@ -227,6 +228,7 @@ export default function Sidebar({ handleNavRowPointerDown={handleNavRowPointerDown} isPlaying={isPlaying} hasNowPlayingTrack={!!currentTrack} + nowPlayingAtTop={nowPlayingAtTop} hasOfflineContent={hasOfflineContent} activeJobsCount={activeJobs.length} cancelAllDownloads={cancelAllDownloads} diff --git a/src/components/settings/SidebarCustomizer.tsx b/src/components/settings/SidebarCustomizer.tsx index b0ae97bb..17da0292 100644 --- a/src/components/settings/SidebarCustomizer.tsx +++ b/src/components/settings/SidebarCustomizer.tsx @@ -39,6 +39,8 @@ export function SidebarCustomizer() { itemsRef.current = items; const randomNavMode = useAuthStore(s => s.randomNavMode); const setRandomNavMode = useAuthStore(s => s.setRandomNavMode); + const nowPlayingAtTop = useAuthStore(s => s.nowPlayingAtTop); + const setNowPlayingAtTop = useAuthStore(s => s.setNowPlayingAtTop); const luckyMixBase = useLuckyMixAvailable(); const luckyMixAvailable = luckyMixBase && randomNavMode === 'separate'; @@ -138,6 +140,20 @@ export function SidebarCustomizer() { +
+
+
{t('settings.nowPlayingTopTitle')}
+
{t('settings.nowPlayingTopDesc')}
+
+ +
{/* Library block */} diff --git a/src/components/sidebar/SidebarNavBody.tsx b/src/components/sidebar/SidebarNavBody.tsx index 59297316..69a74f21 100644 --- a/src/components/sidebar/SidebarNavBody.tsx +++ b/src/components/sidebar/SidebarNavBody.tsx @@ -41,6 +41,7 @@ interface Props { handleNavRowPointerDown: (e: React.PointerEvent, section: 'library' | 'system', sectionIdx: number) => void; isPlaying: boolean; hasNowPlayingTrack: boolean; + nowPlayingAtTop: boolean; hasOfflineContent: boolean; activeJobsCount: number; cancelAllDownloads: () => void; @@ -60,14 +61,32 @@ export default function SidebarNavBody(props: Props) { visibleSystemConfigs, systemItemsForReorder, playlistsExpanded, setPlaylistsExpanded, playlists, playlistsLoading, newReleasesUnreadCount, navDnd, navDndRowClass, handleNavRowPointerDown, - isPlaying, hasNowPlayingTrack, hasOfflineContent, + isPlaying, hasNowPlayingTrack, nowPlayingAtTop, hasOfflineContent, activeJobsCount, cancelAllDownloads, isSyncing, syncJobDone, syncJobSkip, syncJobFail, syncJobTotal, } = props; const { t } = useTranslation(); + // Now Playing — fixed entry (not hideable). Rendered either pinned at the + // very top of the sidebar or after the bottom spacer, per the user setting. + const nowPlayingLink = ( + `nav-link nav-link-nowplaying ${isActive ? 'active' : ''}`} + data-tooltip={isCollapsed ? t('sidebar.nowPlaying') : undefined} + data-tooltip-pos="bottom" + > + + + {isPlaying && hasNowPlayingTrack && } + + {!isCollapsed && {t('sidebar.nowPlaying')}} + + ); + return ( <> + {nowPlayingAtTop && nowPlayingLink} {!isCollapsed && (showLibraryPicker ? ( - {/* Now Playing — fixed, always visible */} - `nav-link nav-link-nowplaying ${isActive ? 'active' : ''}`} - data-tooltip={isCollapsed ? t('sidebar.nowPlaying') : undefined} - data-tooltip-pos="bottom" - > - - - {isPlaying && hasNowPlayingTrack && } - - {!isCollapsed && {t('sidebar.nowPlaying')}} - + {/* Now Playing — pinned at the bottom unless the user moved it to the top. */} + {!nowPlayingAtTop && nowPlayingLink} {hasOfflineContent && ( { @@ -32,6 +33,7 @@ export function createDiscoveryActions(set: SetState): Pick< setRandomMixSize: (v) => set({ randomMixSize: clampRandomMixSize(v) }), setShowLuckyMixMenu: (v) => set({ showLuckyMixMenu: v }), setRandomNavMode: (v) => set({ randomNavMode: v }), + setNowPlayingAtTop: (v) => set({ nowPlayingAtTop: v }), setInfiniteQueueEnabled: (v) => set({ infiniteQueueEnabled: v }), setPreservePlayNextOrder: (v) => set({ preservePlayNextOrder: v }), }; diff --git a/src/store/authStore.ts b/src/store/authStore.ts index 94dac15c..b67bd62b 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -116,6 +116,7 @@ export const useAuthStore = create()( randomMixSize: 50, showLuckyMixMenu: true, randomNavMode: 'hub', + nowPlayingAtTop: false, musicFolders: [], musicLibraryFilterByServer: {}, musicLibraryFilterVersion: 0, diff --git a/src/store/authStoreTypes.ts b/src/store/authStoreTypes.ts index 41bfcd0b..90d762a3 100644 --- a/src/store/authStoreTypes.ts +++ b/src/store/authStoreTypes.ts @@ -373,6 +373,10 @@ export interface AuthState { randomNavMode: 'hub' | 'separate'; setRandomNavMode: (v: 'hub' | 'separate') => void; + /** Pin the fixed "Now Playing" sidebar entry to the top instead of the bottom. */ + nowPlayingAtTop: boolean; + setNowPlayingAtTop: (v: boolean) => void; + logout: () => void; // Derived