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)
This commit is contained in:
Frank Stellmacher
2026-06-05 12:35:39 +02:00
committed by GitHub
parent 5f345dc7aa
commit c39465dfad
16 changed files with 75 additions and 14 deletions
+10
View File
@@ -11,6 +11,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [1.48.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 ## Changed
### Dependencies — npm and Rust refresh ### Dependencies — npm and Rust refresh
+2
View File
@@ -65,6 +65,7 @@ export default function Sidebar({
const sidebarItems = useSidebarStore(s => s.items); const sidebarItems = useSidebarStore(s => s.items);
const setSidebarItems = useSidebarStore(s => s.setItems); const setSidebarItems = useSidebarStore(s => s.setItems);
const randomNavMode = useAuthStore(s => s.randomNavMode); const randomNavMode = useAuthStore(s => s.randomNavMode);
const nowPlayingAtTop = useAuthStore(s => s.nowPlayingAtTop);
const luckyMixBase = useLuckyMixAvailable(); const luckyMixBase = useLuckyMixAvailable();
// Sidebar surfaces Lucky Mix as its own entry only in "separate" nav mode — // 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. // in hub mode it lives inside the Build-a-Mix landing page instead.
@@ -227,6 +228,7 @@ export default function Sidebar({
handleNavRowPointerDown={handleNavRowPointerDown} handleNavRowPointerDown={handleNavRowPointerDown}
isPlaying={isPlaying} isPlaying={isPlaying}
hasNowPlayingTrack={!!currentTrack} hasNowPlayingTrack={!!currentTrack}
nowPlayingAtTop={nowPlayingAtTop}
hasOfflineContent={hasOfflineContent} hasOfflineContent={hasOfflineContent}
activeJobsCount={activeJobs.length} activeJobsCount={activeJobs.length}
cancelAllDownloads={cancelAllDownloads} cancelAllDownloads={cancelAllDownloads}
@@ -39,6 +39,8 @@ export function SidebarCustomizer() {
itemsRef.current = items; itemsRef.current = items;
const randomNavMode = useAuthStore(s => s.randomNavMode); const randomNavMode = useAuthStore(s => s.randomNavMode);
const setRandomNavMode = useAuthStore(s => s.setRandomNavMode); const setRandomNavMode = useAuthStore(s => s.setRandomNavMode);
const nowPlayingAtTop = useAuthStore(s => s.nowPlayingAtTop);
const setNowPlayingAtTop = useAuthStore(s => s.setNowPlayingAtTop);
const luckyMixBase = useLuckyMixAvailable(); const luckyMixBase = useLuckyMixAvailable();
const luckyMixAvailable = luckyMixBase && randomNavMode === 'separate'; const luckyMixAvailable = luckyMixBase && randomNavMode === 'separate';
@@ -138,6 +140,20 @@ export function SidebarCustomizer() {
<span className="toggle-track" /> <span className="toggle-track" />
</label> </label>
</div> </div>
<div className="settings-toggle-row" data-settings-search={t('settings.nowPlayingTopTitle')}>
<div>
<div style={{ fontWeight: 500 }}>{t('settings.nowPlayingTopTitle')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.nowPlayingTopDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.nowPlayingTopTitle')}>
<input
type="checkbox"
checked={nowPlayingAtTop}
onChange={e => setNowPlayingAtTop(e.target.checked)}
/>
<span className="toggle-track" />
</label>
</div>
</div> </div>
<div ref={containerRef} onMouseMove={handleMouseMove} style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}> <div ref={containerRef} onMouseMove={handleMouseMove} style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{/* Library block */} {/* Library block */}
+22 -14
View File
@@ -41,6 +41,7 @@ interface Props {
handleNavRowPointerDown: (e: React.PointerEvent, section: 'library' | 'system', sectionIdx: number) => void; handleNavRowPointerDown: (e: React.PointerEvent, section: 'library' | 'system', sectionIdx: number) => void;
isPlaying: boolean; isPlaying: boolean;
hasNowPlayingTrack: boolean; hasNowPlayingTrack: boolean;
nowPlayingAtTop: boolean;
hasOfflineContent: boolean; hasOfflineContent: boolean;
activeJobsCount: number; activeJobsCount: number;
cancelAllDownloads: () => void; cancelAllDownloads: () => void;
@@ -60,14 +61,32 @@ export default function SidebarNavBody(props: Props) {
visibleSystemConfigs, systemItemsForReorder, visibleSystemConfigs, systemItemsForReorder,
playlistsExpanded, setPlaylistsExpanded, playlists, playlistsLoading, playlistsExpanded, setPlaylistsExpanded, playlists, playlistsLoading,
newReleasesUnreadCount, navDnd, navDndRowClass, handleNavRowPointerDown, newReleasesUnreadCount, navDnd, navDndRowClass, handleNavRowPointerDown,
isPlaying, hasNowPlayingTrack, hasOfflineContent, isPlaying, hasNowPlayingTrack, nowPlayingAtTop, hasOfflineContent,
activeJobsCount, cancelAllDownloads, activeJobsCount, cancelAllDownloads,
isSyncing, syncJobDone, syncJobSkip, syncJobFail, syncJobTotal, isSyncing, syncJobDone, syncJobSkip, syncJobFail, syncJobTotal,
} = props; } = props;
const { t } = useTranslation(); 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 = (
<NavLink
to="/now-playing"
className={({ isActive }) => `nav-link nav-link-nowplaying ${isActive ? 'active' : ''}`}
data-tooltip={isCollapsed ? t('sidebar.nowPlaying') : undefined}
data-tooltip-pos="bottom"
>
<span className="nav-np-icon-wrap">
<AudioLines size={isCollapsed ? 22 : 18} />
{isPlaying && hasNowPlayingTrack && <span className="nav-np-dot" />}
</span>
{!isCollapsed && <span>{t('sidebar.nowPlaying')}</span>}
</NavLink>
);
return ( return (
<> <>
{nowPlayingAtTop && nowPlayingLink}
{!isCollapsed && (showLibraryPicker ? ( {!isCollapsed && (showLibraryPicker ? (
<SidebarLibraryPicker <SidebarLibraryPicker
filterId={filterId} filterId={filterId}
@@ -195,19 +214,8 @@ export default function SidebarNavBody(props: Props) {
{/* What's New banner — only visible while the current release hasn't been seen. */} {/* What's New banner — only visible while the current release hasn't been seen. */}
<WhatsNewBanner collapsed={isCollapsed} /> <WhatsNewBanner collapsed={isCollapsed} />
{/* Now Playing — fixed, always visible */} {/* Now Playing — pinned at the bottom unless the user moved it to the top. */}
<NavLink {!nowPlayingAtTop && nowPlayingLink}
to="/now-playing"
className={({ isActive }) => `nav-link nav-link-nowplaying ${isActive ? 'active' : ''}`}
data-tooltip={isCollapsed ? t('sidebar.nowPlaying') : undefined}
data-tooltip-pos="bottom"
>
<span className="nav-np-icon-wrap">
<AudioLines size={isCollapsed ? 22 : 18} />
{isPlaying && hasNowPlayingTrack && <span className="nav-np-dot" />}
</span>
{!isCollapsed && <span>{t('sidebar.nowPlaying')}</span>}
</NavLink>
{hasOfflineContent && ( {hasOfflineContent && (
<NavLink <NavLink
+2
View File
@@ -374,6 +374,8 @@ export const settings = {
advancedBadge: 'Erweitert', advancedBadge: 'Erweitert',
sidebarDrag: 'Ziehen zum Umsortieren', sidebarDrag: 'Ziehen zum Umsortieren',
sidebarFixed: 'Immer sichtbar', sidebarFixed: 'Immer sichtbar',
nowPlayingTopTitle: 'Now Playing oben',
nowPlayingTopDesc: '"Now Playing" oben in der Seitenleiste anheften statt unten.',
randomNavSplitTitle: 'Mix-Navigation aufteilen', randomNavSplitTitle: 'Mix-Navigation aufteilen',
randomNavSplitDesc: '"Zufallsmix", "Zufallsalben" und "Glücks-Mix" als separate Sidebar-Einträge statt als "Mix erstellen"-Hub anzeigen.', randomNavSplitDesc: '"Zufallsmix", "Zufallsalben" und "Glücks-Mix" als separate Sidebar-Einträge statt als "Mix erstellen"-Hub anzeigen.',
tabInput: 'Eingabe', tabInput: 'Eingabe',
+2
View File
@@ -438,6 +438,8 @@ export const settings = {
advancedBadge: 'Advanced', advancedBadge: 'Advanced',
sidebarDrag: 'Drag to reorder', sidebarDrag: 'Drag to reorder',
sidebarFixed: 'Always visible', sidebarFixed: 'Always visible',
nowPlayingTopTitle: 'Now Playing at top',
nowPlayingTopDesc: 'Pin the "Now Playing" entry to the top of the sidebar instead of the bottom.',
randomNavSplitTitle: 'Split Mix navigation', randomNavSplitTitle: 'Split Mix navigation',
randomNavSplitDesc: 'Show "Random Mix", "Random Albums", and "Lucky Mix" as separate sidebar entries instead of the "Build a Mix" hub.', randomNavSplitDesc: 'Show "Random Mix", "Random Albums", and "Lucky Mix" as separate sidebar entries instead of the "Build a Mix" hub.',
tabInput: 'Input', tabInput: 'Input',
+2
View File
@@ -373,6 +373,8 @@ export const settings = {
advancedBadge: 'Avanzado', advancedBadge: 'Avanzado',
sidebarDrag: 'Arrastra para reordenar', sidebarDrag: 'Arrastra para reordenar',
sidebarFixed: 'Siempre visible', sidebarFixed: 'Siempre visible',
nowPlayingTopTitle: '"Reproduciendo Ahora" arriba',
nowPlayingTopDesc: 'Fijar la entrada "Reproduciendo Ahora" en la parte superior de la barra lateral en lugar de abajo.',
randomNavSplitTitle: 'Dividir navegación Mix', randomNavSplitTitle: 'Dividir navegación Mix',
randomNavSplitDesc: 'Mostrar "Mezcla Aleatoria", "Álbumes Aleatorios" y "Mezcla Suerte" como entradas separadas en la barra lateral en lugar del hub "Crear Mezcla".', randomNavSplitDesc: 'Mostrar "Mezcla Aleatoria", "Álbumes Aleatorios" y "Mezcla Suerte" como entradas separadas en la barra lateral en lugar del hub "Crear Mezcla".',
tabInput: 'Entrada', tabInput: 'Entrada',
+2
View File
@@ -371,6 +371,8 @@ export const settings = {
advancedBadge: 'Avancé', advancedBadge: 'Avancé',
sidebarDrag: 'Glisser pour réorganiser', sidebarDrag: 'Glisser pour réorganiser',
sidebarFixed: 'Toujours visible', sidebarFixed: 'Toujours visible',
nowPlayingTopTitle: '« En cours » en haut',
nowPlayingTopDesc: 'Épingler l\'entrée « En cours » en haut de la barre latérale au lieu du bas.',
randomNavSplitTitle: 'Diviser la navigation Mix', randomNavSplitTitle: 'Diviser la navigation Mix',
randomNavSplitDesc: 'Afficher "Mix Aléatoire", "Albums Aléatoires" et "Mix Chance" comme entrées séparées dans la barre latérale plutôt que le hub "Créer un Mix".', randomNavSplitDesc: 'Afficher "Mix Aléatoire", "Albums Aléatoires" et "Mix Chance" comme entrées séparées dans la barre latérale plutôt que le hub "Créer un Mix".',
tabInput: 'Entrée', tabInput: 'Entrée',
+2
View File
@@ -370,6 +370,8 @@ export const settings = {
advancedBadge: 'Avansert', advancedBadge: 'Avansert',
sidebarDrag: 'Dra for å endre rekkefølge', sidebarDrag: 'Dra for å endre rekkefølge',
sidebarFixed: 'Alltid synlig', sidebarFixed: 'Alltid synlig',
nowPlayingTopTitle: '"Spilles nå" øverst',
nowPlayingTopDesc: 'Fest "Spilles nå" øverst i sidefeltet i stedet for nederst.',
randomNavSplitTitle: 'Del Mix-navigasjon', randomNavSplitTitle: 'Del Mix-navigasjon',
randomNavSplitDesc: 'Vis "Tilfeldig miks", "Tilfeldige album" og "Lykkemiks" som separate sidefeltsoppføringer i stedet for "Lag en miks"-huben.', randomNavSplitDesc: 'Vis "Tilfeldig miks", "Tilfeldige album" og "Lykkemiks" som separate sidefeltsoppføringer i stedet for "Lag en miks"-huben.',
tabShortcuts: 'Snarveier', tabShortcuts: 'Snarveier',
+2
View File
@@ -371,6 +371,8 @@ export const settings = {
advancedBadge: 'Geavanceerd', advancedBadge: 'Geavanceerd',
sidebarDrag: 'Slepen om te herordenen', sidebarDrag: 'Slepen om te herordenen',
sidebarFixed: 'Altijd zichtbaar', sidebarFixed: 'Altijd zichtbaar',
nowPlayingTopTitle: '"Nu bezig" bovenaan',
nowPlayingTopDesc: 'Zet "Nu bezig" bovenaan de zijbalk in plaats van onderaan.',
randomNavSplitTitle: 'Mix-navigatie splitsen', randomNavSplitTitle: 'Mix-navigatie splitsen',
randomNavSplitDesc: 'Toon "Willekeurige mix", "Willekeurige albums" en "Geluksmix" als afzonderlijke zijbalkitems in plaats van de "Mix samenstellen"-hub.', randomNavSplitDesc: 'Toon "Willekeurige mix", "Willekeurige albums" en "Geluksmix" als afzonderlijke zijbalkitems in plaats van de "Mix samenstellen"-hub.',
tabInput: 'Invoer', tabInput: 'Invoer',
+2
View File
@@ -376,6 +376,8 @@ export const settings = {
advancedBadge: 'Avansat', advancedBadge: 'Avansat',
sidebarDrag: 'Trage pentru a reordona', sidebarDrag: 'Trage pentru a reordona',
sidebarFixed: 'Mereu vizibil', sidebarFixed: 'Mereu vizibil',
nowPlayingTopTitle: '"Se Redă Acum" sus',
nowPlayingTopDesc: 'Fixează intrarea "Se Redă Acum" în partea de sus a barei laterale în loc de jos.',
randomNavSplitTitle: 'Navigare Mix împărțită', randomNavSplitTitle: 'Navigare Mix împărțită',
randomNavSplitDesc: 'Arată "Mix Aleatoriu", "Albume Aleatorii", și "Mix Norocos" ca intrări separate în bara laterală în loc de hub-ul "Construiește un Mix".', randomNavSplitDesc: 'Arată "Mix Aleatoriu", "Albume Aleatorii", și "Mix Norocos" ca intrări separate în bara laterală în loc de hub-ul "Construiește un Mix".',
tabInput: 'Input', tabInput: 'Input',
+2
View File
@@ -452,6 +452,8 @@ export const settings = {
advancedBadge: 'Дополнительно', advancedBadge: 'Дополнительно',
sidebarDrag: 'Перетащите для порядка', sidebarDrag: 'Перетащите для порядка',
sidebarFixed: 'Всегда показывать', sidebarFixed: 'Всегда показывать',
nowPlayingTopTitle: '«Сейчас играет» вверху',
nowPlayingTopDesc: 'Закрепить пункт «Сейчас играет» вверху боковой панели вместо нижней части.',
randomNavSplitTitle: 'Разделить навигацию микса', randomNavSplitTitle: 'Разделить навигацию микса',
randomNavSplitDesc: randomNavSplitDesc:
'Показывать «Случайный микс», «Случайные альбомы» и «Мне повезёт» как отдельные пункты боковой панели вместо хаба «Собрать микс».', 'Показывать «Случайный микс», «Случайные альбомы» и «Мне повезёт» как отдельные пункты боковой панели вместо хаба «Собрать микс».',
+2
View File
@@ -370,6 +370,8 @@ export const settings = {
advancedBadge: '高级', advancedBadge: '高级',
sidebarDrag: '拖动以重新排序', sidebarDrag: '拖动以重新排序',
sidebarFixed: '始终显示', sidebarFixed: '始终显示',
nowPlayingTopTitle: '"正在播放"置顶',
nowPlayingTopDesc: '将"正在播放"固定在侧边栏顶部,而非底部。',
randomNavSplitTitle: '拆分混音导航', randomNavSplitTitle: '拆分混音导航',
randomNavSplitDesc: '在侧边栏中将“随机混音”、“随机专辑”和“好运混音”显示为独立条目,而非“创建混音”合并入口。', randomNavSplitDesc: '在侧边栏中将“随机混音”、“随机专辑”和“好运混音”显示为独立条目,而非“创建混音”合并入口。',
tabInput: '输入', tabInput: '输入',
+2
View File
@@ -21,6 +21,7 @@ export function createDiscoveryActions(set: SetState): Pick<
| 'setRandomMixSize' | 'setRandomMixSize'
| 'setShowLuckyMixMenu' | 'setShowLuckyMixMenu'
| 'setRandomNavMode' | 'setRandomNavMode'
| 'setNowPlayingAtTop'
| 'setInfiniteQueueEnabled' | 'setInfiniteQueueEnabled'
| 'setPreservePlayNextOrder' | 'setPreservePlayNextOrder'
> { > {
@@ -32,6 +33,7 @@ export function createDiscoveryActions(set: SetState): Pick<
setRandomMixSize: (v) => set({ randomMixSize: clampRandomMixSize(v) }), setRandomMixSize: (v) => set({ randomMixSize: clampRandomMixSize(v) }),
setShowLuckyMixMenu: (v) => set({ showLuckyMixMenu: v }), setShowLuckyMixMenu: (v) => set({ showLuckyMixMenu: v }),
setRandomNavMode: (v) => set({ randomNavMode: v }), setRandomNavMode: (v) => set({ randomNavMode: v }),
setNowPlayingAtTop: (v) => set({ nowPlayingAtTop: v }),
setInfiniteQueueEnabled: (v) => set({ infiniteQueueEnabled: v }), setInfiniteQueueEnabled: (v) => set({ infiniteQueueEnabled: v }),
setPreservePlayNextOrder: (v) => set({ preservePlayNextOrder: v }), setPreservePlayNextOrder: (v) => set({ preservePlayNextOrder: v }),
}; };
+1
View File
@@ -116,6 +116,7 @@ export const useAuthStore = create<AuthState>()(
randomMixSize: 50, randomMixSize: 50,
showLuckyMixMenu: true, showLuckyMixMenu: true,
randomNavMode: 'hub', randomNavMode: 'hub',
nowPlayingAtTop: false,
musicFolders: [], musicFolders: [],
musicLibraryFilterByServer: {}, musicLibraryFilterByServer: {},
musicLibraryFilterVersion: 0, musicLibraryFilterVersion: 0,
+4
View File
@@ -373,6 +373,10 @@ export interface AuthState {
randomNavMode: 'hub' | 'separate'; randomNavMode: 'hub' | 'separate';
setRandomNavMode: (v: 'hub' | 'separate') => void; 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; logout: () => void;
// Derived // Derived