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() {
+
{/* 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