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
+2
View File
@@ -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}
@@ -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() {
<span className="toggle-track" />
</label>
</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 ref={containerRef} onMouseMove={handleMouseMove} style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{/* Library block */}
+22 -14
View File
@@ -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 = (
<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 (
<>
{nowPlayingAtTop && nowPlayingLink}
{!isCollapsed && (showLibraryPicker ? (
<SidebarLibraryPicker
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. */}
<WhatsNewBanner collapsed={isCollapsed} />
{/* Now Playing — fixed, always visible */}
<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>
{/* Now Playing — pinned at the bottom unless the user moved it to the top. */}
{!nowPlayingAtTop && nowPlayingLink}
{hasOfflineContent && (
<NavLink