refactor(sidebar): co-locate sidebar feature into features/sidebar

This commit is contained in:
Psychotoxical
2026-06-29 23:26:00 +02:00
parent e6269c7b85
commit e0e10e0034
34 changed files with 117 additions and 108 deletions
+308
View File
@@ -0,0 +1,308 @@
import { useState, useRef, useEffect, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { usePlayerStore } from '@/store/playerStore';
import { useOfflineJobStore } from '@/store/offlineJobStore';
import { clearOfflinePinTasks } from '@/utils/offline/offlinePinQueue';
import { useDeviceSyncJobStore } from '@/features/deviceSync';
import { useAuthStore } from '@/store/authStore';
import { useSidebarStore } from '@/features/sidebar/store/sidebarStore';
import { useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { PanelLeft, PanelLeftClose, Trash2 } from 'lucide-react';
import PsysonicLogo from '@/components/PsysonicLogo';
import PSmallLogo from '@/components/PSmallLogo';
import { usePlaylistStore } from '@/store/playlistStore';
import OverlayScrollArea from '@/ui/OverlayScrollArea';
import {
getLibraryItemsForReorder,
getSystemItemsForReorder,
} from '@/features/sidebar/utils/sidebarNavReorder';
import { useLuckyMixAvailable } from '@/hooks/useLuckyMixAvailable';
import { usePerfProbeFlags } from '@/utils/perf/perfFlags';
import { useSidebarNewReleasesUnread } from '@/features/sidebar/hooks/useSidebarNewReleasesUnread';
import { useSidebarNavDnd } from '@/features/sidebar/hooks/useSidebarNavDnd';
import { useSidebarLibraryDropdown } from '@/features/sidebar/hooks/useSidebarLibraryDropdown';
import { useSidebarScrollVisible } from '@/features/sidebar/hooks/useSidebarScrollVisible';
import { isOfflineSidebarNavAllowed } from '@/utils/offline/offlineNavPolicy';
import { useOfflineBrowseContext } from '@/hooks/useOfflineBrowseContext';
import { offlineBrowseNavFlags } from '@/utils/offline/offlineBrowseContext';
import { useSidebarPerfProbe } from '@/features/sidebar/hooks/useSidebarPerfProbe';
import SidebarPerfProbeModal from '@/features/sidebar/components/SidebarPerfProbeModal';
import SidebarNavBody from '@/features/sidebar/components/SidebarNavBody';
export default function Sidebar({
isCollapsed = false,
toggleCollapse,
}: {
isCollapsed?: boolean;
toggleCollapse?: () => void;
}) {
const { t } = useTranslation();
const location = useLocation();
const isPlaying = usePlayerStore(s => s.isPlaying);
const currentTrack = usePlayerStore(s => s.currentTrack);
const offlineJobs = useOfflineJobStore(s => s.jobs);
const pinQueue = useOfflineJobStore(s => s.pinQueue);
const cancelAllDownloadsStore = useOfflineJobStore(s => s.cancelAllDownloads);
const activeJobs = offlineJobs.filter(j => j.status === 'queued' || j.status === 'downloading');
const activePin = pinQueue.find(p => p.status === 'downloading')
?? pinQueue.find(p => p.status === 'queued');
const queuedPinCount = pinQueue.filter(p => p.status === 'queued').length;
const cancelAllDownloads = () => {
clearOfflinePinTasks();
cancelAllDownloadsStore();
};
const syncJobStatus = useDeviceSyncJobStore(s => s.status);
const syncJobDone = useDeviceSyncJobStore(s => s.done);
const syncJobSkip = useDeviceSyncJobStore(s => s.skipped);
const syncJobFail = useDeviceSyncJobStore(s => s.failed);
const syncJobTotal = useDeviceSyncJobStore(s => s.total);
const isSyncing = syncJobStatus === 'running';
const offlineCtx = useOfflineBrowseContext();
const offlineNav = offlineBrowseNavFlags(offlineCtx.capabilities);
const serverId = useAuthStore(s => s.activeServerId ?? '');
const isLoggedIn = useAuthStore(s => s.isLoggedIn);
const musicFolders = useAuthStore(s => s.musicFolders);
const musicLibraryFilterByServer = useAuthStore(s => s.musicLibraryFilterByServer);
const setMusicLibraryFilter = useAuthStore(s => s.setMusicLibraryFilter);
const hotCacheEnabled = useAuthStore(s => s.hotCacheEnabled);
const setHotCacheEnabled = useAuthStore(s => s.setHotCacheEnabled);
const normalizationEngine = useAuthStore(s => s.normalizationEngine);
const setNormalizationEngine = useAuthStore(s => s.setNormalizationEngine);
const loggingMode = useAuthStore(s => s.loggingMode);
const setLoggingMode = useAuthStore(s => s.setLoggingMode);
const hasOfflineContent = offlineCtx.capabilities.manualPins;
const isServerOffline = offlineCtx.active;
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.
const luckyMixAvailable = luckyMixBase && randomNavMode === 'separate';
const { libraryDropdownOpen, setLibraryDropdownOpen, dropdownRect, libraryTriggerRef } =
useSidebarLibraryDropdown();
const [playlistsExpanded, setPlaylistsExpanded] = useState(false);
const playlistsRaw = usePlaylistStore(s => s.playlists);
const playlistsLoading = usePlaylistStore(s => s.playlistsLoading);
const fetchPlaylists = usePlaylistStore(s => s.fetchPlaylists);
// Sort playlists alphabetically by name
const playlists = useMemo(() => {
return [...playlistsRaw].sort((a, b) => a.name.localeCompare(b.name));
}, [playlistsRaw]);
const [sidebarViewportEl, setSidebarViewportEl] = useState<HTMLDivElement | null>(null);
const isSidebarScrolling = useSidebarScrollVisible(sidebarViewportEl);
const showLibraryPicker = !isCollapsed && isLoggedIn && musicFolders.length > 1 && !isServerOffline;
const filterId = serverId ? (musicLibraryFilterByServer[serverId] ?? 'all') : 'all';
const selectedFolderName =
filterId === 'all' ? null : musicFolders.find(f => f.id === filterId)?.name ?? null;
const libraryItemsForReorder = useMemo(
() => getLibraryItemsForReorder(sidebarItems, randomNavMode),
[sidebarItems, randomNavMode],
);
const systemItemsForReorder = useMemo(
() => getSystemItemsForReorder(sidebarItems),
[sidebarItems],
);
const visibleLibraryConfigs = useMemo(
() =>
libraryItemsForReorder.filter(c => {
if (!c.visible) return false;
if (c.id === 'luckyMix' && !luckyMixAvailable) return false;
if (isServerOffline && !isOfflineSidebarNavAllowed(
c.id,
offlineNav.favoritesOfflineBrowse,
offlineNav.localLibraryBrowse,
offlineNav.playerStatsBrowse,
offlineNav.playlistsOfflineBrowse,
)) {
return false;
}
return true;
}),
[libraryItemsForReorder, luckyMixAvailable, isServerOffline, offlineNav],
);
const visibleSystemConfigs = useMemo(
() => systemItemsForReorder.filter(c => {
if (!c.visible) return false;
if (isServerOffline && !isOfflineSidebarNavAllowed(
c.id,
offlineNav.favoritesOfflineBrowse,
offlineNav.localLibraryBrowse,
offlineNav.playerStatsBrowse,
offlineNav.playlistsOfflineBrowse,
)) {
return false;
}
return true;
}),
[systemItemsForReorder, isServerOffline, offlineNav],
);
const sidebarItemsRef = useRef(sidebarItems);
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
// eslint-disable-next-line react-hooks/refs
sidebarItemsRef.current = sidebarItems;
const {
navDnd,
navDndTrashHint,
suppressNavClickRef,
handleNavRowPointerDown,
navDndRowClass,
} = useSidebarNavDnd({
isCollapsed,
sidebarItemsRef,
setSidebarItems,
});
const newReleasesUnreadCount = useSidebarNewReleasesUnread({
serverId,
filterId,
isLoggedIn,
pathname: location.pathname,
});
const { perfProbeOpen, setPerfProbeOpen } = useSidebarPerfProbe();
const perfFlags = usePerfProbeFlags();
const pickLibrary = (id: 'all' | string) => {
if (isServerOffline) return;
setMusicLibraryFilter(id);
setLibraryDropdownOpen(false);
};
useEffect(() => {
if (isServerOffline) setLibraryDropdownOpen(false);
}, [isServerOffline, setLibraryDropdownOpen]);
// Fetch playlists when expanded
useEffect(() => {
if (!playlistsExpanded || !isLoggedIn) return;
fetchPlaylists();
}, [playlistsExpanded, isLoggedIn, fetchPlaylists]);
return (
<>
<aside className={`sidebar animate-slide-in ${isCollapsed ? 'collapsed' : ''}`}>
<div className="sidebar-brand" aria-hidden>
{isCollapsed
? <PSmallLogo style={{ height: '32px', width: 'auto' }} />
: <PsysonicLogo style={{ height: '28px', width: 'auto' }} />
}
</div>
<button
className="collapse-btn"
onClick={toggleCollapse}
style={{
opacity: isSidebarScrolling ? 0 : 1,
pointerEvents: isSidebarScrolling ? 'none' : 'auto',
}}
data-tooltip={isCollapsed ? t('sidebar.expand') : t('sidebar.collapse')}
data-tooltip-pos="right"
>
{isCollapsed ? <PanelLeft size={14} /> : <PanelLeftClose size={14} />}
</button>
<nav
className="sidebar-nav"
aria-label="Main navigation"
onClickCapture={e => {
if (suppressNavClickRef.current) {
suppressNavClickRef.current = false;
e.preventDefault();
e.stopPropagation();
}
}}
>
<OverlayScrollArea
className="sidebar-nav-scroll"
viewportClassName="sidebar-nav-viewport"
viewportRef={setSidebarViewportEl}
railInset="panel"
measureDeps={[
isCollapsed,
playlistsExpanded,
playlists.length,
isLoggedIn,
randomNavMode,
filterId,
hasOfflineContent,
activeJobs.length,
isSyncing,
syncJobTotal,
sidebarItems.length,
]}
>
<SidebarNavBody
isCollapsed={isCollapsed}
showLibraryPicker={showLibraryPicker}
filterId={filterId}
selectedFolderName={selectedFolderName}
libraryDropdownOpen={libraryDropdownOpen}
setLibraryDropdownOpen={setLibraryDropdownOpen}
dropdownRect={dropdownRect}
libraryTriggerRef={libraryTriggerRef}
musicFolders={musicFolders}
pickLibrary={pickLibrary}
visibleLibraryConfigs={visibleLibraryConfigs}
visibleSystemConfigs={visibleSystemConfigs}
playlistsExpanded={playlistsExpanded}
setPlaylistsExpanded={setPlaylistsExpanded}
playlists={playlists}
playlistsLoading={playlistsLoading}
newReleasesUnreadCount={newReleasesUnreadCount}
navDnd={navDnd}
navDndRowClass={navDndRowClass}
handleNavRowPointerDown={handleNavRowPointerDown}
isPlaying={isPlaying}
hasNowPlayingTrack={!!currentTrack}
nowPlayingAtTop={nowPlayingAtTop}
hasOfflineContent={hasOfflineContent}
activeJobsCount={activeJobs.length}
activePinName={activePin?.albumName ?? null}
queuedPinCount={queuedPinCount}
cancelAllDownloads={cancelAllDownloads}
isSyncing={isSyncing}
syncJobDone={syncJobDone}
syncJobSkip={syncJobSkip}
syncJobFail={syncJobFail}
syncJobTotal={syncJobTotal}
/>
</OverlayScrollArea>
</nav>
</aside>
{navDndTrashHint != null &&
createPortal(
<div
className="sidebar-nav-dnd-trash-hint"
style={{
position: 'fixed',
left: navDndTrashHint.x + 14,
top: navDndTrashHint.y + 14,
}}
aria-hidden
>
<Trash2 size={22} strokeWidth={2.25} />
</div>,
document.body,
)}
<SidebarPerfProbeModal
open={perfProbeOpen}
onClose={() => setPerfProbeOpen(false)}
perfFlags={perfFlags}
hotCacheEnabled={hotCacheEnabled}
setHotCacheEnabled={setHotCacheEnabled}
normalizationEngine={normalizationEngine}
setNormalizationEngine={setNormalizationEngine}
loggingMode={loggingMode}
setLoggingMode={setLoggingMode}
/>
</>
);
}
@@ -0,0 +1,71 @@
import { useTranslation } from 'react-i18next';
import { HardDriveDownload, HardDriveUpload, X } from 'lucide-react';
interface Props {
isCollapsed: boolean;
activeJobsCount: number;
activePinName: string | null;
queuedPinCount: number;
cancelAllDownloads: () => void;
isSyncing: boolean;
syncJobDone: number;
syncJobSkip: number;
syncJobFail: number;
syncJobTotal: number;
}
export default function SidebarActiveJobs({
isCollapsed, activeJobsCount, activePinName, queuedPinCount, cancelAllDownloads,
isSyncing, syncJobDone, syncJobSkip, syncJobFail, syncJobTotal,
}: Props) {
const { t } = useTranslation();
const showPinQueue = !!activePinName || queuedPinCount > 0;
const offlineQueueLabel = showPinQueue
? (queuedPinCount > 0
? t('sidebar.offlinePinActiveQueued', { name: activePinName ?? '', queued: queuedPinCount })
: t('sidebar.offlinePinActive', { name: activePinName ?? '' }))
: t('sidebar.downloadingTracks', { n: activeJobsCount });
const syncLabel = t('sidebar.syncingTracks', {
done: syncJobDone + syncJobSkip + syncJobFail,
total: syncJobTotal,
});
return (
<>
{(activeJobsCount > 0 || showPinQueue) && (
<div
className={`sidebar-offline-queue ${isCollapsed ? 'sidebar-offline-queue--collapsed' : ''}`}
data-tooltip={offlineQueueLabel}
data-tooltip-pos="right"
>
<HardDriveDownload size={isCollapsed ? 18 : 14} className="spin-slow" />
{!isCollapsed && (
<span>{offlineQueueLabel}</span>
)}
<button
className="sidebar-offline-cancel"
onClick={cancelAllDownloads}
data-tooltip={t('sidebar.cancelDownload')}
data-tooltip-pos="right"
aria-label={t('sidebar.cancelDownload')}
>
<X size={12} />
</button>
</div>
)}
{isSyncing && (
<div
className={`sidebar-offline-queue sidebar-sync-queue ${isCollapsed ? 'sidebar-offline-queue--collapsed' : ''}`}
data-tooltip={syncLabel}
data-tooltip-pos="right"
>
<HardDriveUpload size={isCollapsed ? 18 : 14} className="spin-slow" />
{!isCollapsed && (
<span>{syncLabel}</span>
)}
</div>
)}
</>
);
}
@@ -0,0 +1,96 @@
import React from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { Check, ChevronDown, Music2 } from 'lucide-react';
interface MusicFolder { id: string; name: string }
interface Props {
filterId: string;
selectedFolderName: string | null;
libraryDropdownOpen: boolean;
setLibraryDropdownOpen: (open: boolean) => void;
dropdownRect: { top: number; left: number; width: number };
libraryTriggerRef: React.RefObject<HTMLButtonElement | null>;
musicFolders: MusicFolder[];
pickLibrary: (id: 'all' | string) => void;
}
export default function SidebarLibraryPicker({
filterId, selectedFolderName, libraryDropdownOpen, setLibraryDropdownOpen,
dropdownRect, libraryTriggerRef, musicFolders, pickLibrary,
}: Props) {
const { t } = useTranslation();
const libraryTriggerPlain = filterId === 'all';
return (
<>
<button
ref={libraryTriggerRef}
type="button"
className={`nav-library-scope-trigger ${libraryTriggerPlain ? 'nav-library-scope-trigger--plain' : ''} ${libraryDropdownOpen ? 'nav-library-scope-trigger--open' : ''}`}
onClick={() => setLibraryDropdownOpen(!libraryDropdownOpen)}
aria-label={t('sidebar.libraryScope')}
aria-expanded={libraryDropdownOpen}
aria-haspopup="listbox"
data-tooltip={libraryDropdownOpen ? undefined : t('sidebar.libraryScope')}
data-tooltip-pos="bottom"
>
{!libraryTriggerPlain ? (
<Music2 size={16} className="nav-library-scope-icon" strokeWidth={2} aria-hidden />
) : null}
<div className="nav-library-scope-text">
<span className="nav-library-scope-title">{t('sidebar.library')}</span>
{selectedFolderName ? (
<span className="nav-library-scope-subtitle" data-tooltip={selectedFolderName} data-tooltip-pos="right">
{selectedFolderName}
</span>
) : null}
</div>
<ChevronDown size={16} strokeWidth={2.25} className="nav-library-scope-chevron" aria-hidden />
</button>
{libraryDropdownOpen &&
createPortal(
<div
className={`nav-library-dropdown-panel${musicFolders.length > 10 ? ' nav-library-dropdown-panel--many-libraries' : ''}`}
role="listbox"
aria-label={t('sidebar.libraryScope')}
style={{
position: 'fixed',
top: dropdownRect.top,
left: dropdownRect.left,
width: dropdownRect.width,
minWidth: dropdownRect.width,
maxWidth: dropdownRect.width,
boxSizing: 'border-box',
}}
>
<button
type="button"
role="option"
aria-selected={filterId === 'all'}
className={`nav-library-dropdown-item ${filterId === 'all' ? 'nav-library-dropdown-item--selected' : ''}`}
onClick={() => pickLibrary('all')}
>
<span className="nav-library-dropdown-item-label">{t('sidebar.allLibraries')}</span>
{filterId === 'all' ? <Check size={16} className="nav-library-dropdown-check" strokeWidth={2.5} /> : <span className="nav-library-dropdown-check-spacer" />}
</button>
{musicFolders.map(f => (
<button
key={f.id}
type="button"
role="option"
aria-selected={filterId === f.id}
className={`nav-library-dropdown-item ${filterId === f.id ? 'nav-library-dropdown-item--selected' : ''}`}
onClick={() => pickLibrary(f.id)}
>
<span className="nav-library-dropdown-item-label">{f.name}</span>
{filterId === f.id ? <Check size={16} className="nav-library-dropdown-check" strokeWidth={2.5} /> : <span className="nav-library-dropdown-check-spacer" />}
</button>
))}
</div>,
document.body
)}
</>
);
}
@@ -0,0 +1,286 @@
import React from 'react';
import { NavLink } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { AudioLines, ChevronRight, HardDriveDownload, Settings } from 'lucide-react';
import type { SidebarItemConfig } from '@/features/sidebar/store/sidebarStore';
import { ALL_NAV_ITEMS } from '@/config/navItems';
import WhatsNewBanner from '@/components/WhatsNewBanner';
import ThemeUpdateBanner from '@/components/ThemeUpdateBanner';
import SidebarLibraryPicker from '@/features/sidebar/components/SidebarLibraryPicker';
import SidebarPlaylistsSection from '@/features/sidebar/components/SidebarPlaylistsSection';
import SidebarActiveJobs from '@/features/sidebar/components/SidebarActiveJobs';
interface NavDndState {
section: 'library' | 'system';
draggedId: string;
}
interface MusicFolder { id: string; name: string }
interface Props {
isCollapsed: boolean;
showLibraryPicker: boolean;
filterId: string;
selectedFolderName: string | null;
libraryDropdownOpen: boolean;
setLibraryDropdownOpen: (open: boolean) => void;
dropdownRect: { top: number; left: number; width: number };
libraryTriggerRef: React.RefObject<HTMLButtonElement | null>;
musicFolders: MusicFolder[];
pickLibrary: (id: 'all' | string) => void;
visibleLibraryConfigs: SidebarItemConfig[];
visibleSystemConfigs: SidebarItemConfig[];
playlistsExpanded: boolean;
setPlaylistsExpanded: (v: boolean) => void;
playlists: { id: string; name: string }[];
playlistsLoading: boolean;
newReleasesUnreadCount: number;
navDnd: NavDndState | null;
navDndRowClass: (section: 'library' | 'system', id: string) => string;
handleNavRowPointerDown: (e: React.PointerEvent, section: 'library' | 'system', id: string) => void;
isPlaying: boolean;
hasNowPlayingTrack: boolean;
nowPlayingAtTop: boolean;
hasOfflineContent: boolean;
activeJobsCount: number;
activePinName: string | null;
queuedPinCount: number;
cancelAllDownloads: () => void;
isSyncing: boolean;
syncJobDone: number;
syncJobSkip: number;
syncJobFail: number;
syncJobTotal: number;
}
export default function SidebarNavBody(props: Props) {
const {
isCollapsed, showLibraryPicker, filterId, selectedFolderName,
libraryDropdownOpen, setLibraryDropdownOpen, dropdownRect, libraryTriggerRef,
musicFolders, pickLibrary,
visibleLibraryConfigs,
visibleSystemConfigs,
playlistsExpanded, setPlaylistsExpanded, playlists, playlistsLoading,
newReleasesUnreadCount, navDnd, navDndRowClass, handleNavRowPointerDown,
isPlaying, hasNowPlayingTrack, nowPlayingAtTop, hasOfflineContent,
activeJobsCount, activePinName, queuedPinCount, 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}
selectedFolderName={selectedFolderName}
libraryDropdownOpen={libraryDropdownOpen}
setLibraryDropdownOpen={setLibraryDropdownOpen}
dropdownRect={dropdownRect}
libraryTriggerRef={libraryTriggerRef}
musicFolders={musicFolders}
pickLibrary={pickLibrary}
/>
) : (
<span className="nav-section-label">{t('sidebar.library')}</span>
))}
{visibleLibraryConfigs.map(cfg => {
const item = ALL_NAV_ITEMS[cfg.id];
if (!item) return null;
const dndRow = !isCollapsed;
const rowClass = dndRow ? navDndRowClass('library', cfg.id) : undefined;
const dndProps = dndRow
? {
'data-sidebar-nav-dnd-row': '',
'data-sidebar-section': 'library' as const,
'data-sidebar-id': cfg.id,
onPointerDown: (e: React.PointerEvent) =>
handleNavRowPointerDown(e, 'library', cfg.id),
}
: {};
return item.to === '/playlists' && !isCollapsed ? (
<div
key={item.to}
className={`sidebar-playlists-wrapper${rowClass ? ` ${rowClass}` : ''}`}
{...dndProps}
style={navDnd && dndRow ? { touchAction: 'none' } : undefined}
>
<div className="sidebar-playlists-header-row">
<NavLink
to={item.to}
className={({ isActive }) => `nav-link sidebar-playlists-main-link ${isActive ? 'active' : ''}`}
>
<item.icon size={18} />
<span>{t(item.labelKey)}</span>
</NavLink>
<button
className={`sidebar-playlists-toggle ${playlistsExpanded ? 'expanded' : ''}`}
onClick={() => setPlaylistsExpanded(!playlistsExpanded)}
aria-expanded={playlistsExpanded}
aria-label={playlistsExpanded ? t('sidebar.collapsePlaylists') : t('sidebar.expandPlaylists')}
data-tooltip={playlistsExpanded ? t('sidebar.collapsePlaylists') : t('sidebar.expandPlaylists')}
>
<ChevronRight size={14} />
</button>
</div>
{playlistsExpanded && (
<SidebarPlaylistsSection playlists={playlists} playlistsLoading={playlistsLoading} />
)}
</div>
) : isCollapsed ? (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
data-tooltip={isCollapsed ? t(item.labelKey) : undefined}
data-tooltip-pos="bottom"
>
<item.icon size={isCollapsed ? 22 : 18} />
{item.to === '/new-releases' && newReleasesUnreadCount > 0 && (
<span className="sidebar-nav-unread-badge" aria-hidden>
{newReleasesUnreadCount > 99 ? '99+' : newReleasesUnreadCount}
</span>
)}
{!isCollapsed && <span>{t(item.labelKey)}</span>}
</NavLink>
) : (
<div
key={item.to}
className={rowClass}
{...dndProps}
style={navDnd && dndRow ? { touchAction: 'none' } : undefined}
>
<NavLink
to={item.to}
end={item.to === '/'}
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
data-tooltip={isCollapsed ? t(item.labelKey) : undefined}
data-tooltip-pos="bottom"
>
<item.icon size={isCollapsed ? 22 : 18} />
{!isCollapsed && <span>{t(item.labelKey)}</span>}
{item.to === '/new-releases' && newReleasesUnreadCount > 0 && (
<span className="sidebar-nav-unread-badge" aria-hidden>
{newReleasesUnreadCount > 99 ? '99+' : newReleasesUnreadCount}
</span>
)}
</NavLink>
</div>
);
})}
{/* Spacer: everything from here onward sticks to the bottom of the sidebar. */}
<div className="sidebar-bottom-spacer" />
{/* What's New banner — only visible while the current release hasn't been seen. */}
<WhatsNewBanner collapsed={isCollapsed} />
{/* Theme-update notice — only visible while an installed theme has an update. */}
<ThemeUpdateBanner collapsed={isCollapsed} />
{/* Now Playing — pinned at the bottom unless the user moved it to the top. */}
{!nowPlayingAtTop && nowPlayingLink}
{hasOfflineContent && (
<NavLink
to="/offline"
className={({ isActive }) => `nav-link nav-link-offline ${isActive ? 'active' : ''}`}
data-tooltip={isCollapsed ? t('sidebar.offlineLibrary') : undefined}
data-tooltip-pos="bottom"
>
<HardDriveDownload size={isCollapsed ? 22 : 18} />
{!isCollapsed && <span>{t('sidebar.offlineLibrary')}</span>}
</NavLink>
)}
{visibleSystemConfigs.length > 0 && !isCollapsed && <span className="nav-section-label">{t('sidebar.system')}</span>}
{visibleSystemConfigs.map(cfg => {
const item = ALL_NAV_ITEMS[cfg.id];
if (!item) return null;
const dndRow = !isCollapsed;
const rowClass = dndRow ? navDndRowClass('system', cfg.id) : undefined;
const dndProps = dndRow
? {
'data-sidebar-nav-dnd-row': '',
'data-sidebar-section': 'system' as const,
'data-sidebar-id': cfg.id,
onPointerDown: (e: React.PointerEvent) =>
handleNavRowPointerDown(e, 'system', cfg.id),
}
: {};
return isCollapsed ? (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
data-tooltip={isCollapsed ? t(item.labelKey) : undefined}
data-tooltip-pos="bottom"
>
<item.icon size={isCollapsed ? 22 : 18} />
{!isCollapsed && <span>{t(item.labelKey)}</span>}
</NavLink>
) : (
<div
key={item.to}
className={rowClass}
{...dndProps}
style={navDnd && dndRow ? { touchAction: 'none' } : undefined}
>
<NavLink
to={item.to}
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
data-tooltip={isCollapsed ? t(item.labelKey) : undefined}
data-tooltip-pos="bottom"
>
<item.icon size={isCollapsed ? 22 : 18} />
{!isCollapsed && <span>{t(item.labelKey)}</span>}
</NavLink>
</div>
);
})}
<NavLink
to="/settings"
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
data-tooltip={isCollapsed ? t('sidebar.settings') : undefined}
data-tooltip-pos="bottom"
>
<Settings size={isCollapsed ? 22 : 18} />
{!isCollapsed && <span>{t('sidebar.settings')}</span>}
</NavLink>
<SidebarActiveJobs
isCollapsed={isCollapsed}
activeJobsCount={activeJobsCount}
activePinName={activePinName}
queuedPinCount={queuedPinCount}
cancelAllDownloads={cancelAllDownloads}
isSyncing={isSyncing}
syncJobDone={syncJobDone}
syncJobSkip={syncJobSkip}
syncJobFail={syncJobFail}
syncJobTotal={syncJobTotal}
/>
</>
);
}
@@ -0,0 +1,156 @@
import { useState } from 'react';
import { Activity, Network, ScrollText, SlidersHorizontal, Wrench, X } from 'lucide-react';
import { createPortal } from 'react-dom';
import SidebarPerfProbeMonitorTab from '@/features/sidebar/components/perfProbe/SidebarPerfProbeMonitorTab';
import SidebarPerfProbeTogglesTab from '@/features/sidebar/components/perfProbe/SidebarPerfProbeTogglesTab';
import SidebarPerfProbeTuningTab from '@/features/sidebar/components/perfProbe/SidebarPerfProbeTuningTab';
import SidebarPerfProbeLogsTab from '@/features/sidebar/components/perfProbe/SidebarPerfProbeLogsTab';
import SidebarPerfProbeConnectionsTab from '@/features/sidebar/components/perfProbe/SidebarPerfProbeConnectionsTab';
import { resetPerfProbeFlags, type PerfProbeFlags } from '@/utils/perf/perfFlags';
import { clearPerfLiveOverlayPins } from '@/utils/perf/perfOverlayPins';
import { resetPerfOverlayAppearance } from '@/utils/perf/perfOverlayAppearance';
import { resetPerfOverlayMode } from '@/utils/perf/perfOverlayMode';
type TabId = 'monitor' | 'connections' | 'toggles' | 'tuning' | 'logs';
interface Props {
open: boolean;
onClose: () => void;
perfFlags: PerfProbeFlags;
hotCacheEnabled: boolean;
setHotCacheEnabled: (v: boolean) => void;
normalizationEngine: string;
setNormalizationEngine: (v: 'off' | 'loudness') => void;
loggingMode: string;
setLoggingMode: (v: 'off' | 'normal') => void;
}
export default function SidebarPerfProbeModal({
open,
onClose,
perfFlags,
hotCacheEnabled,
setHotCacheEnabled,
normalizationEngine,
setNormalizationEngine,
loggingMode,
setLoggingMode,
}: Props) {
const [tab, setTab] = useState<TabId>('monitor');
if (!open) return null;
const resetAll = () => {
resetPerfProbeFlags();
clearPerfLiveOverlayPins();
resetPerfOverlayAppearance();
resetPerfOverlayMode();
};
return createPortal(
<div
className="modal-overlay modal-overlay--perf-probe"
onClick={() => onClose()}
role="dialog"
aria-modal="true"
aria-labelledby="psylab-title"
>
<div
className="modal-content sidebar-perf-modal"
onClick={e => e.stopPropagation()}
>
<button type="button" className="modal-close" onClick={() => onClose()} aria-label="Close">
<X size={18} />
</button>
<header className="sidebar-perf-modal__header">
<h3 id="psylab-title" className="modal-title">PsyLab</h3>
<p className="sidebar-perf-modal__hint">
Live metrics, server connections, runtime tuning, and diagnostic disable toggles.
</p>
</header>
<div className="sidebar-perf-modal__tabs" role="tablist" aria-label="PsyLab sections">
<button
type="button"
role="tab"
aria-selected={tab === 'monitor'}
className={`sidebar-perf-modal__tab${tab === 'monitor' ? ' sidebar-perf-modal__tab--active' : ''}`}
onClick={() => setTab('monitor')}
>
<Activity size={15} />
Monitor
</button>
<button
type="button"
role="tab"
aria-selected={tab === 'connections'}
className={`sidebar-perf-modal__tab${tab === 'connections' ? ' sidebar-perf-modal__tab--active' : ''}`}
onClick={() => setTab('connections')}
>
<Network size={15} />
Connections
</button>
<button
type="button"
role="tab"
aria-selected={tab === 'toggles'}
className={`sidebar-perf-modal__tab${tab === 'toggles' ? ' sidebar-perf-modal__tab--active' : ''}`}
onClick={() => setTab('toggles')}
>
<SlidersHorizontal size={15} />
Toggles
</button>
<button
type="button"
role="tab"
aria-selected={tab === 'tuning'}
className={`sidebar-perf-modal__tab${tab === 'tuning' ? ' sidebar-perf-modal__tab--active' : ''}`}
onClick={() => setTab('tuning')}
>
<Wrench size={15} />
Tuning
</button>
<button
type="button"
role="tab"
aria-selected={tab === 'logs'}
className={`sidebar-perf-modal__tab${tab === 'logs' ? ' sidebar-perf-modal__tab--active' : ''}`}
onClick={() => setTab('logs')}
>
<ScrollText size={15} />
Logs
</button>
</div>
<div className={`sidebar-perf-modal__body${tab === 'logs' ? ' sidebar-perf-modal__body--logs' : ''}`}>
{tab === 'monitor' && <SidebarPerfProbeMonitorTab />}
{tab === 'connections' && <SidebarPerfProbeConnectionsTab />}
{tab === 'tuning' && <SidebarPerfProbeTuningTab />}
{tab === 'toggles' && (
<SidebarPerfProbeTogglesTab
perfFlags={perfFlags}
hotCacheEnabled={hotCacheEnabled}
setHotCacheEnabled={setHotCacheEnabled}
normalizationEngine={normalizationEngine}
setNormalizationEngine={setNormalizationEngine}
loggingMode={loggingMode}
setLoggingMode={setLoggingMode}
/>
)}
{tab === 'logs' && <SidebarPerfProbeLogsTab />}
</div>
<div className="sidebar-perf-modal__actions">
<button type="button" className="btn btn-ghost" onClick={resetAll}>
Reset all
</button>
<button type="button" className="btn btn-primary" onClick={() => onClose()}>
Close
</button>
</div>
</div>
</div>,
document.body,
);
}
@@ -0,0 +1,100 @@
import React from 'react';
import { NavLink } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { ChevronRight, Folder, PlayCircle, Sparkles } from 'lucide-react';
import { displayPlaylistName, isSmartPlaylistName } from '@/features/sidebar/utils/sidebarHelpers';
import { useAuthStore } from '@/store/authStore';
import { usePlayerStore } from '@/store/playerStore';
import { usePlaylistStore } from '@/store/playlistStore';
import { EMPTY_SERVER_FOLDERS, usePlaylistFolderStore } from '@/store/playlistFolderStore';
import { groupPlaylistsByFolder } from '@/utils/playlist/playlistFolders';
interface SidebarPlaylist {
id: string;
name: string;
}
interface Props {
playlists: SidebarPlaylist[];
playlistsLoading: boolean;
}
/**
* Sidebar playlist list, grouped into collapsible folders when the active
* server has any. Folder state comes from the shared local folder store;
* creation / rename / deletion lives on the Playlists page, while assignment
* works here via each playlist's right-click menu ("Move to folder"). With no
* folders this renders the original flat list (plus right-click support).
*/
export default function SidebarPlaylistsSection({ playlists, playlistsLoading }: Props) {
const { t } = useTranslation();
const serverId = useAuthStore(s => s.activeServerId);
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const fullPlaylists = usePlaylistStore(s => s.playlists);
const bucket =
usePlaylistFolderStore(s => (serverId ? s.byServer[serverId] : undefined)) ?? EMPTY_SERVER_FOLDERS;
const toggleFolderCollapsed = usePlaylistFolderStore(s => s.toggleFolderCollapsed);
if (playlistsLoading) {
return (
<div className="sidebar-playlists-list">
<div className="sidebar-playlists-loading">
<div className="spinner" style={{ width: 14, height: 14 }} />
</div>
</div>
);
}
if (playlists.length === 0) {
return (
<div className="sidebar-playlists-list">
<div className="sidebar-playlists-empty">{t('playlists.empty')}</div>
</div>
);
}
const renderItem = (pl: SidebarPlaylist) => (
<NavLink
key={pl.id}
to={`/playlists/${pl.id}`}
className={({ isActive }) => `nav-link sidebar-playlist-item ${isActive ? 'active' : ''}`}
onContextMenu={e => {
e.preventDefault();
const full = fullPlaylists.find(p => p.id === pl.id) ?? pl;
openContextMenu(e.clientX, e.clientY, full, 'playlist');
}}
>
{isSmartPlaylistName(pl.name) ? <Sparkles size={12} /> : <PlayCircle size={12} />}
<span>{displayPlaylistName(pl.name)}</span>
</NavLink>
);
if (!serverId || bucket.folders.length === 0) {
return <div className="sidebar-playlists-list">{playlists.map(renderItem)}</div>;
}
const grouped = groupPlaylistsByFolder(playlists, bucket.folders, bucket.assignments);
return (
<div className="sidebar-playlists-list">
{grouped.folders.map(({ folder, playlists: items }) => (
<div key={folder.id} className="sidebar-playlist-folder">
<button
className={`sidebar-playlist-folder-header${folder.collapsed ? '' : ' expanded'}`}
onClick={() => toggleFolderCollapsed(serverId, folder.id)}
aria-expanded={!folder.collapsed}
aria-label={folder.collapsed ? t('playlists.folders.expandFolder') : t('playlists.folders.collapseFolder')}
>
<ChevronRight size={12} className="sidebar-playlist-folder-chevron" />
<Folder size={12} />
<span className="sidebar-playlist-folder-name">{folder.name}</span>
<span className="sidebar-playlist-folder-count">{items.length}</span>
</button>
{!folder.collapsed && items.length > 0 && (
<div className="sidebar-playlist-folder-items">{items.map(renderItem)}</div>
)}
</div>
))}
{grouped.ungrouped.map(renderItem)}
</div>
);
}
@@ -0,0 +1,95 @@
import { useEffect, useRef, useState } from 'react';
import {
coverGetPipelineQueueStats,
libraryCoverBackfillResetCursor,
libraryCoverBackfillRunFullPass,
libraryCoverBackfillSetParallel,
} from '@/api/coverCache';
const COVER_THREADS_MIN = 1;
const COVER_THREADS_MAX = 16;
/**
* PsyLab tuning knob for cover backfill concurrency (download + encode pools
* move together). Deliberately not surfaced in app Settings — it is a live
* diagnostics/experiment control. The value is process-local and resets to the
* backend default on app restart.
*/
export default function PerfCoverThreadsControl() {
const [threads, setThreads] = useState<number | null>(null);
const pendingRef = useRef(false);
useEffect(() => {
let cancelled = false;
void coverGetPipelineQueueStats()
.then(stats => {
if (!cancelled) setThreads(stats.libraryBackfillHttpMax || COVER_THREADS_MIN);
})
.catch(() => {
if (!cancelled) setThreads(COVER_THREADS_MIN);
});
return () => {
cancelled = true;
};
}, []);
const apply = (next: number) => {
setThreads(next);
if (pendingRef.current) return;
pendingRef.current = true;
void libraryCoverBackfillSetParallel(next)
.then(applied => setThreads(applied))
.catch(() => {})
.finally(() => {
pendingRef.current = false;
});
};
const value = threads ?? COVER_THREADS_MIN;
const [running, setRunning] = useState(false);
const runPass = () => {
if (running) return;
setRunning(true);
void libraryCoverBackfillResetCursor()
.then(() => libraryCoverBackfillRunFullPass(true))
.catch(() => {})
.finally(() => setRunning(false));
};
return (
<section className="perf-live-poll" aria-label="Cover backfill threads">
<div className="perf-live-poll__title">Cover backfill</div>
<label className="perf-live-poll__row">
<span className="perf-live-poll__label">
Threads
{' '}
<span className="perf-live-poll__value">{value}</span>
</span>
<input
type="range"
min={COVER_THREADS_MIN}
max={COVER_THREADS_MAX}
step={1}
value={value}
disabled={threads === null}
onChange={e => apply(Number(e.target.value))}
/>
</label>
<button
type="button"
className="perf-live-poll__run"
onClick={runPass}
disabled={running}
>
{running ? 'Starting…' : 'Run full pass now'}
</button>
<p className="perf-live-poll__hint">
Download + encode concurrency for background cover warm-up. Higher = faster
backfill but more CPU/network while it runs. Resets to default on restart.
lib /N in the cover overlay only fills when covers are actually missing
clear a servers cover cache first, then run a pass.
</p>
</section>
);
}
@@ -0,0 +1,52 @@
import { perfLiveCpuSnapshotSupported } from '@/utils/perf/perfLiveCpuSnapshot';
import {
PERF_LIVE_POLL_MS_MAX,
PERF_LIVE_POLL_MS_MIN,
PERF_LIVE_POLL_MS_STEP,
setPerfLiveIncludeThreadGroups,
setPerfLivePollIntervalMs,
usePerfLiveIncludeThreadGroups,
usePerfLivePollIntervalMs,
} from '@/utils/perf/perfLivePollSettings';
export default function PerfLivePollControls() {
const pollMs = usePerfLivePollIntervalMs();
const includeThreadGroups = usePerfLiveIncludeThreadGroups();
if (!perfLiveCpuSnapshotSupported()) return null;
const pollSec = (pollMs / 1000).toFixed(1);
return (
<section className="perf-live-poll" aria-label="Live poll interval">
<div className="perf-live-poll__title">Live sampling</div>
<label className="perf-live-poll__row">
<span className="perf-live-poll__label">
Poll interval
{' '}
<span className="perf-live-poll__value">{pollSec}s</span>
</span>
<input
type="range"
min={PERF_LIVE_POLL_MS_MIN}
max={PERF_LIVE_POLL_MS_MAX}
step={PERF_LIVE_POLL_MS_STEP}
value={pollMs}
onChange={e => setPerfLivePollIntervalMs(Number(e.target.value))}
/>
</label>
<label className="perf-live-poll__row perf-live-poll__row--check">
<input
type="checkbox"
checked={includeThreadGroups}
onChange={e => setPerfLiveIncludeThreadGroups(e.target.checked)}
/>
<span className="perf-live-poll__check-label">
CPU by psysonic threads
</span>
</label>
<p className="perf-live-poll__hint">
Scans in-process thread groups each poll (Linux). Off by default enable only while diagnosing.
</p>
</section>
);
}
@@ -0,0 +1,48 @@
import {
PERF_OVERLAY_CORNER_OPTIONS,
setPerfOverlayCorner,
setPerfOverlayOpacity,
usePerfOverlayAppearance,
} from '@/utils/perf/perfOverlayAppearance';
export default function PerfOverlayAppearanceControls() {
const { corner, opacity } = usePerfOverlayAppearance();
return (
<section className="perf-overlay-appearance" aria-label="Overlay layout">
<div className="perf-overlay-appearance__title">Layout</div>
<div className="perf-overlay-appearance__row">
<span className="perf-overlay-appearance__label">Corner</span>
<div className="perf-overlay-appearance__corners" role="group" aria-label="Overlay corner">
{PERF_OVERLAY_CORNER_OPTIONS.map(opt => (
<button
key={opt.id}
type="button"
className={`perf-overlay-appearance__corner${corner === opt.id ? ' perf-overlay-appearance__corner--active' : ''}`}
aria-pressed={corner === opt.id}
onClick={() => setPerfOverlayCorner(opt.id)}
title={opt.label}
>
<span className={`perf-overlay-appearance__corner-mark perf-overlay-appearance__corner-mark--${opt.id}`} />
</button>
))}
</div>
</div>
<label className="perf-overlay-appearance__row perf-overlay-appearance__opacity">
<span className="perf-overlay-appearance__label">
Opacity
{' '}
<span className="perf-overlay-appearance__value">{Math.round(opacity * 100)}%</span>
</span>
<input
type="range"
min={25}
max={100}
step={5}
value={Math.round(opacity * 100)}
onChange={e => setPerfOverlayOpacity(Number(e.target.value) / 100)}
/>
</label>
</section>
);
}
@@ -0,0 +1,33 @@
import {
PERF_OVERLAY_MODE_OPTIONS,
setPerfOverlayMode,
usePerfOverlayMode,
} from '@/utils/perf/perfOverlayMode';
export default function PerfOverlayModeControls() {
const mode = usePerfOverlayMode();
return (
<section className="perf-overlay-mode" aria-label="Overlay mode">
<div className="perf-overlay-mode__title">On-screen overlay</div>
<div className="perf-overlay-mode__segments" role="group" aria-label="Overlay mode">
{PERF_OVERLAY_MODE_OPTIONS.map(option => (
<button
key={option.id}
type="button"
className={`perf-overlay-mode__segment${mode === option.id ? ' perf-overlay-mode__segment--active' : ''}`}
aria-pressed={mode === option.id}
onClick={() => setPerfOverlayMode(option.id)}
>
{option.label}
</button>
))}
</div>
<p className="perf-overlay-mode__hint">
{mode === 'off' && 'Overlay hidden.'}
{mode === 'fps' && 'Shows only the FPS counter.'}
{mode === 'pinned' && 'Shows metrics pinned in Monitor (pipeline + live).'}
</p>
</section>
);
}
@@ -0,0 +1,23 @@
import type { ReactNode } from 'react';
export interface PerfProbeDetailRow {
label: string;
value: ReactNode;
}
interface Props {
rows: PerfProbeDetailRow[];
}
export default function PerfProbeDetailList({ rows }: Props) {
return (
<dl className="perf-server-dl">
{rows.map(row => (
<div key={row.label} className="perf-server-dl__row">
<dt>{row.label}</dt>
<dd>{row.value}</dd>
</div>
))}
</dl>
);
}
@@ -0,0 +1,102 @@
import type { ReactNode } from 'react';
import { ChevronRight, Pin, PinOff } from 'lucide-react';
type PinKind = 'live' | 'pipeline';
interface Props {
label: string;
value: string;
unit?: string;
detail?: string;
barPct?: number;
barTone?: 'cpu' | 'memory' | 'rate' | 'neutral';
pinned?: boolean;
pinKind?: PinKind;
onTogglePin?: () => void;
disabled?: boolean;
}
export default function PerfProbeMetricCard({
label,
value,
unit,
detail,
barPct,
barTone = 'neutral',
pinned = false,
pinKind,
onTogglePin,
disabled = false,
}: Props) {
const showBar = barPct != null && Number.isFinite(barPct) && barPct > 0;
return (
<div className={`perf-metric-card${disabled ? ' perf-metric-card--disabled' : ''}`}>
<div className="perf-metric-card__head">
<div className="perf-metric-card__label">{label}</div>
{onTogglePin && (
<button
type="button"
className={`perf-metric-card__pin${pinned ? ' perf-metric-card__pin--active' : ''}`}
onClick={onTogglePin}
aria-pressed={pinned}
title={pinned ? 'Remove from overlay' : 'Show in overlay'}
>
{pinned ? <PinOff size={14} /> : <Pin size={14} />}
</button>
)}
</div>
<div className="perf-metric-card__value-row">
<span className="perf-metric-card__value">{value}</span>
{unit && <span className="perf-metric-card__unit">{unit}</span>}
</div>
{showBar && (
<div className="perf-metric-card__bar-track" aria-hidden="true">
<div
className={`perf-metric-card__bar-fill perf-metric-card__bar-fill--${barTone}`}
style={{ width: `${Math.min(100, barPct)}%` }}
/>
</div>
)}
{detail && <div className="perf-metric-card__detail">{detail}</div>}
{pinKind === 'pipeline' && pinned && (
<div className="perf-metric-card__badge">Pipeline overlay</div>
)}
</div>
);
}
interface SectionProps {
title: string;
hint?: string;
children: ReactNode;
defaultOpen?: boolean;
layout?: 'grid' | 'stack';
onOpenChange?: (open: boolean) => void;
}
export function PerfProbeMetricSection({
title,
hint,
children,
defaultOpen = true,
layout = 'grid',
onOpenChange,
}: SectionProps) {
return (
<details
className="perf-metric-section"
open={defaultOpen}
onToggle={e => onOpenChange?.((e.currentTarget as HTMLDetailsElement).open)}
>
<summary className="perf-metric-section__title">
<ChevronRight size={14} className="perf-metric-section__chevron" />
<span>{title}</span>
{hint && <span className="perf-metric-section__hint">{hint}</span>}
</summary>
<div className={layout === 'stack' ? 'perf-metric-section__body' : 'perf-metric-section__grid'}>
{children}
</div>
</details>
);
}
@@ -0,0 +1,16 @@
import type { ReactNode } from 'react';
export type PerfProbeBadgeTone = 'ok' | 'warn' | 'error' | 'neutral' | 'muted';
interface Props {
tone: PerfProbeBadgeTone;
children: ReactNode;
}
export default function PerfProbeStatusBadge({ tone, children }: Props) {
return (
<span className={`perf-status-badge perf-status-badge--${tone}`}>
{children}
</span>
);
}
@@ -0,0 +1,129 @@
import { useAuthStore } from '@/store/authStore';
import { usePlayerStore } from '@/store/playerStore';
import { useConnectionStatus } from '@/hooks/useConnectionStatus';
import { useNavidromeAdminRole } from '@/hooks/useNavidromeAdminRole';
import { serverListDisplayLabel } from '@/utils/server/serverDisplayName';
import { findServerByIdOrIndexKey } from '@/utils/server/serverLookup';
import { PerfProbeMetricSection } from '@/features/sidebar/components/perfProbe/PerfProbeMetricCard';
import PerfProbeDetailList from '@/features/sidebar/components/perfProbe/PerfProbeDetailList';
import PerfProbeStatusBadge, { type PerfProbeBadgeTone } from '@/features/sidebar/components/perfProbe/PerfProbeStatusBadge';
import SidebarPerfProbeServerSection from '@/features/sidebar/components/perfProbe/SidebarPerfProbeServerSection';
function connectionStatusBadge(status: string): { tone: PerfProbeBadgeTone; label: string } {
switch (status) {
case 'connected': return { tone: 'ok', label: 'Connected' };
case 'disconnected': return { tone: 'error', label: 'Disconnected' };
case 'checking': return { tone: 'warn', label: 'Checking…' };
default: return { tone: 'muted', label: status };
}
}
function sessionBadge(loggedIn: boolean): { tone: PerfProbeBadgeTone; label: string } {
return loggedIn
? { tone: 'ok', label: 'Logged in' }
: { tone: 'muted', label: 'Not logged in' };
}
function adminRoleBadge(role: ReturnType<typeof useNavidromeAdminRole>): { tone: PerfProbeBadgeTone; label: string } {
switch (role) {
case 'admin': return { tone: 'ok', label: 'Admin' };
case 'user': return { tone: 'neutral', label: 'Standard user' };
case 'checking':
case 'idle': return { tone: 'warn', label: 'Checking…' };
case 'error': return { tone: 'error', label: 'Could not verify' };
case 'na':
default: return { tone: 'muted', label: 'N/A (not Navidrome)' };
}
}
function endpointBadge(isLan: boolean): { tone: PerfProbeBadgeTone; label: string } {
return isLan
? { tone: 'ok', label: 'LAN' }
: { tone: 'neutral', label: 'Public / remote' };
}
export default function SidebarPerfProbeConnectionsTab() {
const { status, isLan, serverName } = useConnectionStatus();
const isLoggedIn = useAuthStore(s => s.isLoggedIn);
const activeServerId = useAuthStore(s => s.activeServerId);
const servers = useAuthStore(s => s.servers);
const connectUrl = useAuthStore(s => s.getBaseUrl());
const queueServerId = usePlayerStore(s => s.queueServerId);
const adminRole = useNavidromeAdminRole();
const queueServer = queueServerId ? findServerByIdOrIndexKey(queueServerId) : undefined;
const queueDiffersFromActive = Boolean(
queueServerId && activeServerId && queueServerId !== activeServerId,
);
const connBadge = connectionStatusBadge(status);
const sessBadge = sessionBadge(isLoggedIn);
const roleBadge = adminRoleBadge(adminRole);
return (
<div className="perf-monitor">
<div className="perf-conn-summary" aria-label="Connection overview">
<div className="perf-conn-summary__item">
<span className="perf-conn-summary__label">Link</span>
<PerfProbeStatusBadge tone={connBadge.tone}>{connBadge.label}</PerfProbeStatusBadge>
</div>
<div className="perf-conn-summary__item">
<span className="perf-conn-summary__label">Session</span>
<PerfProbeStatusBadge tone={sessBadge.tone}>{sessBadge.label}</PerfProbeStatusBadge>
</div>
{isLoggedIn && (
<div className="perf-conn-summary__item">
<span className="perf-conn-summary__label">Role</span>
<PerfProbeStatusBadge tone={roleBadge.tone}>{roleBadge.label}</PerfProbeStatusBadge>
</div>
)}
</div>
<PerfProbeMetricSection title="Connection" defaultOpen layout="stack">
<PerfProbeDetailList
rows={[
{
label: 'Status',
value: <PerfProbeStatusBadge tone={connBadge.tone}>{connBadge.label}</PerfProbeStatusBadge>,
},
{
label: 'Session',
value: <PerfProbeStatusBadge tone={sessBadge.tone}>{sessBadge.label}</PerfProbeStatusBadge>,
},
...(isLoggedIn
? [{
label: 'Navidrome role',
value: <PerfProbeStatusBadge tone={roleBadge.tone}>{roleBadge.label}</PerfProbeStatusBadge>,
}]
: []),
...(serverName ? [{ label: 'Browse label', value: serverName }] : []),
...(connectUrl ? [{ label: 'Connect URL', value: <code className="perf-server-dl__code">{connectUrl}</code> }] : []),
...(status === 'connected'
? [{
label: 'Endpoint',
value: (
<PerfProbeStatusBadge tone={endpointBadge(isLan).tone}>
{endpointBadge(isLan).label}
</PerfProbeStatusBadge>
),
}]
: []),
]}
/>
</PerfProbeMetricSection>
<SidebarPerfProbeServerSection adminRole={adminRole} />
{queueDiffersFromActive && queueServer && (
<PerfProbeMetricSection title="Queue playback server" defaultOpen={false} layout="stack">
<PerfProbeDetailList
rows={[
{ label: 'Name', value: serverListDisplayLabel(queueServer, servers) },
{ label: 'Scope key', value: <code className="perf-server-dl__code">{queueServerId}</code> },
]}
/>
</PerfProbeMetricSection>
)}
</div>
);
}
@@ -0,0 +1,396 @@
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type MouseEvent as ReactMouseEvent,
} from 'react';
import { Copy, Download, Pause, Play, Trash2 } from 'lucide-react';
import { createPortal } from 'react-dom';
import { save as saveDialog } from '@tauri-apps/plugin-dialog';
import { writeFile } from '@tauri-apps/plugin-fs';
import { getLoggingMode, tailRuntimeLogs, type RuntimeLogLine } from '@/api/runtimeLogs';
import { invoke } from '@tauri-apps/api/core';
import { useAuthStore } from '@/store/authStore';
import type { LoggingMode } from '@/store/authStoreTypes';
import CustomSelect from '@/ui/CustomSelect';
import { filterLogLines } from '@/utils/perf/filterLogLines';
import { sanitizeLogLine } from '@/utils/perf/sanitizeLogLine';
function formatLogLinesText(lines: RuntimeLogLine[]): string {
return lines.map(line => line.text).join('\n');
}
const POLL_MS = 750;
const BOTTOM_EPSILON = 24;
// Hard ceiling for the in-view buffer while the user has scrolled up (so history
// they are reading is not trimmed away). Matches the backend ring buffer size.
const MAX_BUFFER = 20_000;
const LINE_CAP_OPTIONS = [
{ value: '500', label: '500 lines' },
{ value: '1000', label: '1000 lines' },
{ value: '2000', label: '2000 lines' },
{ value: '5000', label: '5000 lines' },
];
const DEPTH_OPTIONS: { value: LoggingMode; label: string }[] = [
{ value: 'off', label: 'Off' },
{ value: 'normal', label: 'Normal' },
{ value: 'debug', label: 'Debug' },
];
/**
* Live view of the backend runtime log buffer (the stdout/stderr lines that are
* otherwise only visible in the launching terminal — unreachable on Windows).
* Polls the ring buffer incrementally, with a depth switch, line cap, and an
* ordered include/exclude word filter.
*/
export default function SidebarPerfProbeLogsTab() {
const loggingMode = useAuthStore(s => s.loggingMode);
const setLoggingMode = useAuthStore(s => s.setLoggingMode);
const [lines, setLines] = useState<RuntimeLogLine[]>([]);
const [paused, setPaused] = useState(false);
const [filter, setFilter] = useState('');
const [lineCap, setLineCap] = useState(1000);
const [follow, setFollow] = useState(true);
const [overflowed, setOverflowed] = useState(false);
const [copyState, setCopyState] = useState<'idle' | 'ok' | 'error'>('idle');
const [exporting, setExporting] = useState(false);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
const contextMenuRef = useRef<HTMLDivElement | null>(null);
const lastSeqRef = useRef<number | null>(null);
const pausedRef = useRef(paused);
const lineCapRef = useRef(lineCap);
const followRef = useRef(follow);
const scrollRef = useRef<HTMLDivElement | null>(null);
// Topmost visible line to re-pin against while the user is scrolled up, so the
// view stays put even as new lines append below or old ones scroll out.
const anchorRef = useRef<{ seq: number; offset: number } | null>(null);
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
// eslint-disable-next-line react-hooks/refs
pausedRef.current = paused;
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
// eslint-disable-next-line react-hooks/refs
lineCapRef.current = lineCap;
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
// eslint-disable-next-line react-hooks/refs
followRef.current = follow;
// Keep the backend mode readout in sync with reality on open.
useEffect(() => {
void getLoggingMode().then(mode => {
if (mode !== loggingMode) setLoggingMode(mode);
}).catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
let cancelled = false;
let timer: number | undefined;
const tick = async () => {
if (!pausedRef.current) {
try {
// While following, request only the visible cap; while scrolled up,
// pull up to the hard ceiling so read-back history is preserved.
const fetchMax = followRef.current ? lineCapRef.current : MAX_BUFFER;
const tail = await tailRuntimeLogs(lastSeqRef.current, fetchMax);
if (!cancelled && tail.dropped) setOverflowed(true);
if (!cancelled && tail.lines.length > 0) {
lastSeqRef.current = tail.lastSeq;
setLines(prev => {
const next = [
...prev,
...tail.lines.map(line => ({
...line,
text: sanitizeLogLine(line.text),
})),
];
// Only trim from the top while following; otherwise keep history
// under the reader's viewport up to the hard ceiling.
const cap = followRef.current ? lineCapRef.current : MAX_BUFFER;
return next.length > cap ? next.slice(next.length - cap) : next;
});
} else if (!cancelled) {
lastSeqRef.current = tail.lastSeq;
}
} catch {
/* transient; retry next tick */
}
}
if (!cancelled) timer = window.setTimeout(() => void tick(), POLL_MS);
};
void tick();
return () => {
cancelled = true;
if (timer != null) window.clearTimeout(timer);
};
}, []);
const visible = useMemo(() => filterLogLines(lines, filter), [lines, filter]);
// When following resumes (or the cap shrinks), trim retained history to the cap.
useEffect(() => {
if (!follow) return;
// React Compiler set-state-in-effect rule: state set from a DOM/layout measurement.
// eslint-disable-next-line react-hooks/set-state-in-effect
setLines(prev => (prev.length > lineCap ? prev.slice(prev.length - lineCap) : prev));
}, [follow, lineCap]);
// Keep the view pinned: stick to the bottom while following, otherwise re-pin
// the previously-topmost line so the reader's position holds as lines append.
useLayoutEffect(() => {
const el = scrollRef.current;
if (!el) return;
if (follow) {
el.scrollTop = el.scrollHeight;
return;
}
const anchor = anchorRef.current;
if (!anchor) return;
const node = el.querySelector<HTMLElement>(`[data-seq="${anchor.seq}"]`);
if (node) el.scrollTop = node.offsetTop - anchor.offset;
}, [visible, follow]);
const captureAnchor = (el: HTMLElement) => {
const top = el.scrollTop;
for (const child of Array.from(el.children) as HTMLElement[]) {
if (child.dataset.seq == null) continue;
if (child.offsetTop + child.offsetHeight > top + 1) {
anchorRef.current = { seq: Number(child.dataset.seq), offset: child.offsetTop - top };
return;
}
}
};
const onScroll = () => {
const el = scrollRef.current;
if (!el) return;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < BOTTOM_EPSILON;
if (!atBottom) captureAnchor(el);
if (atBottom !== followRef.current) setFollow(atBottom);
};
const jumpToLatest = () => {
anchorRef.current = null;
setFollow(true);
};
const changeDepth = (mode: LoggingMode) => {
setLoggingMode(mode);
void invoke('set_logging_mode', { mode }).catch(() => {});
};
const clear = () => {
setLines([]);
setOverflowed(false);
};
const selectedLogText = useCallback(() => {
const el = scrollRef.current;
const sel = window.getSelection();
if (!el || !sel || sel.isCollapsed || sel.rangeCount === 0) return '';
const range = sel.getRangeAt(0);
if (!el.contains(range.commonAncestorContainer)) return '';
return sel.toString();
}, []);
const copyText = async (text: string, feedback = true) => {
if (!text) return false;
try {
await navigator.clipboard.writeText(text);
if (feedback) setCopyState('ok');
return true;
} catch {
if (feedback) setCopyState('error');
return false;
} finally {
if (feedback) window.setTimeout(() => setCopyState('idle'), 1600);
}
};
const copyAllShown = () => copyText(formatLogLinesText(visible));
const copySelection = () => copyText(selectedLogText(), false);
const closeContextMenu = useCallback(() => setContextMenu(null), []);
useEffect(() => {
if (!contextMenu) return;
const onDown = (e: MouseEvent) => {
if (contextMenuRef.current?.contains(e.target as Node)) return;
closeContextMenu();
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') closeContextMenu();
};
document.addEventListener('mousedown', onDown);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDown);
document.removeEventListener('keydown', onKey);
};
}, [contextMenu, closeContextMenu]);
const onLogContextMenu = (e: ReactMouseEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
const selected = selectedLogText().trim();
if (!selected) return;
setContextMenu({ x: e.clientX, y: e.clientY });
};
const exportVisible = async () => {
if (visible.length === 0 || exporting) return;
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
const selected = await saveDialog({
defaultPath: `psysonic-psylab-logs-${stamp}.log`,
filters: [{ name: 'Log files', extensions: ['log', 'txt'] }],
title: 'Export shown log lines',
});
if (!selected || Array.isArray(selected)) return;
setExporting(true);
try {
const bytes = new TextEncoder().encode(`${formatLogLinesText(visible)}\n`);
await writeFile(selected, bytes);
} catch {
/* user cancelled or write failed */
} finally {
setExporting(false);
}
};
return (
<div className="perf-logs">
<div className="perf-logs__controls">
<label className="perf-logs__control">
<span className="perf-logs__control-label">Depth</span>
<CustomSelect
value={loggingMode}
onChange={v => changeDepth(v as LoggingMode)}
options={DEPTH_OPTIONS}
/>
</label>
<label className="perf-logs__control">
<span className="perf-logs__control-label">Keep</span>
<CustomSelect
value={String(lineCap)}
onChange={v => setLineCap(Number(v))}
options={LINE_CAP_OPTIONS}
/>
</label>
<button
type="button"
className="perf-logs__btn"
onClick={() => setPaused(p => !p)}
aria-pressed={paused}
title={paused ? 'Resume live tail' : 'Pause live tail'}
>
{paused ? <Play size={14} /> : <Pause size={14} />}
{paused ? 'Resume' : 'Pause'}
</button>
<button type="button" className="perf-logs__btn" onClick={clear} title="Clear view">
<Trash2 size={14} />
Clear
</button>
<button
type="button"
className="perf-logs__btn"
onClick={() => void copyAllShown()}
disabled={visible.length === 0}
title="Copy all lines currently shown in the log view"
>
<Copy size={14} />
{copyState === 'ok' ? 'Copied' : copyState === 'error' ? 'Copy failed' : 'Copy'}
</button>
<button
type="button"
className="perf-logs__btn"
onClick={() => void exportVisible()}
disabled={visible.length === 0 || exporting}
title="Export shown lines to a file"
>
<Download size={14} />
{exporting ? 'Exporting…' : 'Export'}
</button>
</div>
<input
type="text"
className="perf-logs__filter"
placeholder="Filter: word to include, -word to exclude, comma-separated (order matters)"
value={filter}
onChange={e => setFilter(e.target.value)}
spellCheck={false}
/>
<div
className="perf-logs__view"
ref={scrollRef}
onScroll={onScroll}
onContextMenu={onLogContextMenu}
role="log"
aria-live="off"
data-selectable
>
{visible.length === 0 ? (
<div className="perf-logs__empty">
{loggingMode === 'off'
? 'Logging is Off — set depth to Normal or Debug to capture lines.'
: lines.length === 0
? 'Waiting for log lines…'
: 'No lines match the current filter.'}
</div>
) : (
visible.map(line => (
<div key={line.seq} data-seq={line.seq} className="perf-logs__line">
{line.text}
</div>
))
)}
</div>
<div className="perf-logs__status">
<span>
{visible.length.toLocaleString()} shown · {lines.length.toLocaleString()} buffered
{overflowed && ' · buffer overflowed (oldest dropped)'}
</span>
{!follow && (
<button
type="button"
className="perf-logs__jump"
onClick={jumpToLatest}
>
Jump to latest
</button>
)}
</div>
{contextMenu && createPortal(
<div
ref={contextMenuRef}
className="context-menu perf-logs__context-menu"
style={{ left: contextMenu.x, top: contextMenu.y }}
onContextMenu={e => e.preventDefault()}
>
<button
type="button"
className="context-menu-item perf-logs__context-item"
onClick={() => {
void copySelection();
closeContextMenu();
}}
>
<Copy size={14} />
Copy
</button>
</div>,
document.body,
)}
</div>
);
}
@@ -0,0 +1,255 @@
import { useMemo, useRef } from 'react';
import { isPerfLivePollWaitingForCpu, usePerfLiveSnapshot } from '@/utils/perf/perfLiveStore';
import { usePerfLiveIncludeThreadGroups } from '@/utils/perf/perfLivePollSettings';
import {
togglePerfLiveOverlayPin,
togglePipelineOverlayPin,
usePerfLiveOverlayPins,
usePipelineOverlayPinned,
type PerfLiveOverlayPinId,
} from '@/utils/perf/perfOverlayPins';
import PerfProbeMetricCard, { PerfProbeMetricSection } from '@/features/sidebar/components/perfProbe/PerfProbeMetricCard';
import PerfOverlayAppearanceControls from '@/features/sidebar/components/perfProbe/PerfOverlayAppearanceControls';
import PerfOverlayModeControls from '@/features/sidebar/components/perfProbe/PerfOverlayModeControls';
import PerfLivePollControls from '@/features/sidebar/components/perfProbe/PerfLivePollControls';
function memoryBarPct(rssKb: number, maxKb: number): number {
if (maxKb <= 0) return 0;
return (rssKb / maxKb) * 100;
}
export default function SidebarPerfProbeMonitorTab() {
const live = usePerfLiveSnapshot();
const livePins = usePerfLiveOverlayPins();
const fpsPinned = usePipelineOverlayPinned('pipeline:fps');
const analysisPinned = usePipelineOverlayPinned('pipeline:analysis');
const coverPinned = usePipelineOverlayPinned('pipeline:cover');
const cpu = live.cpu;
const cpuSupported = cpu?.supported === true;
const collecting = isPerfLivePollWaitingForCpu();
const includeThreadGroups = usePerfLiveIncludeThreadGroups();
const peakMemoryKbRef = useRef(1);
const peakThreadCpuRef = useRef(1);
const maxMemoryKb = useMemo(() => {
const current = Math.max(1, ...(cpu?.memory.map(m => m.rss_kb) ?? [1]));
// React Compiler refs rule: ref read imperatively outside reactive rendering; not used to compute the render output.
// eslint-disable-next-line react-hooks/refs
if (current > peakMemoryKbRef.current) peakMemoryKbRef.current = current;
return peakMemoryKbRef.current;
}, [cpu?.memory]);
// React Compiler refs rule: ref read imperatively outside reactive rendering; not used to compute the render output.
// eslint-disable-next-line react-hooks/refs
const maxThreadCpu = useMemo(() => {
const current = Math.max(1, ...(cpu?.threadCpu.map(t => t.pct) ?? [1]));
if (current > peakThreadCpuRef.current) peakThreadCpuRef.current = current;
return peakThreadCpuRef.current;
}, [cpu?.threadCpu]);
if (collecting) {
return (
<div className="perf-monitor-empty">
<div className="spinner" style={{ width: 22, height: 22 }} />
<span>Collecting live samples</span>
</div>
);
}
const toggleLive = (id: PerfLiveOverlayPinId) => () => togglePerfLiveOverlayPin(id);
const livePinned = (id: PerfLiveOverlayPinId) => livePins.has(id);
return (
<div className="perf-monitor">
<PerfOverlayModeControls />
<PerfOverlayAppearanceControls />
<PerfLivePollControls />
<PerfProbeMetricSection title="Pipeline overlays" hint="Rust / UI queues">
<PerfProbeMetricCard
label="FPS"
value="—"
detail="requestAnimationFrame rate"
pinned={fpsPinned}
pinKind="pipeline"
onTogglePin={() => togglePipelineOverlayPin('pipeline:fps')}
/>
<PerfProbeMetricCard
label="Analysis"
value="—"
detail="Throughput + last track timings"
pinned={analysisPinned}
pinKind="pipeline"
onTogglePin={() => togglePipelineOverlayPin('pipeline:analysis')}
/>
<PerfProbeMetricCard
label="Cover pipeline"
value="—"
detail="Ensure / HTTP / encode queues"
pinned={coverPinned}
pinKind="pipeline"
onTogglePin={() => togglePipelineOverlayPin('pipeline:cover')}
/>
</PerfProbeMetricSection>
{cpu && !cpuSupported && (
<div className="perf-monitor-empty perf-monitor-empty--inline">
Live CPU and RSS sampling is unavailable on this platform. Pipeline, UI rate, and analysis metrics below still work.
</div>
)}
{cpuSupported && cpu && (
<>
<PerfProbeMetricSection title="CPU — processes">
<PerfProbeMetricCard
label="psysonic"
value={cpu.app.toFixed(1)}
unit="%"
barPct={cpu.app}
barTone="cpu"
pinned={livePinned('cpu:app')}
onTogglePin={toggleLive('cpu:app')}
/>
<PerfProbeMetricCard
label="WebKit web"
value={cpu.webkit.toFixed(1)}
unit="%"
barPct={cpu.webkit}
barTone="cpu"
pinned={livePinned('cpu:webkit')}
onTogglePin={toggleLive('cpu:webkit')}
/>
</PerfProbeMetricSection>
{includeThreadGroups && (
<PerfProbeMetricSection
title="CPU — psysonic threads"
defaultOpen
>
{/* React Compiler refs rule: the row renderer reads pin-state refs (live overlay); intentional, not reactive render data. */}
{/* eslint-disable-next-line react-hooks/refs */}
{cpu.threadCpu.length > 0 ? cpu.threadCpu.map(row => {
const pinId = `cpu:thread:${row.label}` as PerfLiveOverlayPinId;
return (
<PerfProbeMetricCard
key={row.label}
label={row.label}
value={row.pct.toFixed(1)}
unit="%"
detail={row.threadCount > 1 ? `${row.threadCount} threads` : undefined}
barPct={(row.pct / maxThreadCpu) * 100}
barTone="cpu"
pinned={livePinned(pinId)}
onTogglePin={toggleLive(pinId)}
/>
);
}) : (
<div className="perf-monitor-empty perf-monitor-empty--inline">
No named psysonic threads yet wait for the next poll or load audio/analysis work.
</div>
)}
</PerfProbeMetricSection>
)}
{cpu.memory.length > 0 && (
<PerfProbeMetricSection title="Memory — RSS">
{/* React Compiler refs rule: the row renderer reads pin-state refs (live overlay); intentional, not reactive render data. */}
{/* eslint-disable-next-line react-hooks/refs */}
{cpu.memory.map(row => {
const pinId = `mem:${row.label}` as PerfLiveOverlayPinId;
return (
<PerfProbeMetricCard
key={row.label}
label={row.label}
value={(row.rss_kb / 1024).toFixed(1)}
unit="MB"
barPct={memoryBarPct(row.rss_kb, maxMemoryKb)}
barTone="memory"
pinned={livePinned(pinId)}
onTogglePin={toggleLive(pinId)}
/>
);
})}
</PerfProbeMetricSection>
)}
</>
)}
{live.diagRates && (
<PerfProbeMetricSection title="UI event rates" defaultOpen={false}>
<PerfProbeMetricCard
label="audio:progress"
value={live.diagRates.progress.toFixed(1)}
unit="/s"
barPct={Math.min(100, live.diagRates.progress * 2)}
barTone="rate"
pinned={livePinned('rate:progress')}
onTogglePin={toggleLive('rate:progress')}
/>
<PerfProbeMetricCard
label="waveform draws"
value={live.diagRates.waveform.toFixed(1)}
unit="/s"
barPct={Math.min(100, live.diagRates.waveform * 2)}
barTone="rate"
pinned={livePinned('rate:waveform')}
onTogglePin={toggleLive('rate:waveform')}
/>
<PerfProbeMetricCard
label="Home commits"
value={live.diagRates.home.toFixed(1)}
unit="/s"
barPct={Math.min(100, live.diagRates.home * 5)}
barTone="rate"
pinned={livePinned('rate:home')}
onTogglePin={toggleLive('rate:home')}
/>
</PerfProbeMetricSection>
)}
{live.analysis && (
<PerfProbeMetricSection title="Analysis" defaultOpen={false}>
<PerfProbeMetricCard
label="Throughput"
value={live.analysis.tracksPerMinute.toFixed(1)}
unit="tpm"
pinned={livePinned('analysis:tpm')}
onTogglePin={toggleLive('analysis:tpm')}
/>
{live.analysis.lastTotalMs != null && (
<PerfProbeMetricCard
label="Last track"
value={(live.analysis.lastTotalMs / 1000).toFixed(1)}
unit="s"
detail={`fetch ${((live.analysis.lastFetchMs ?? 0) / 1000).toFixed(1)}s · seed ${((live.analysis.lastSeedMs ?? 0) / 1000).toFixed(1)}s · bpm ${((live.analysis.lastBpmMs ?? 0) / 1000).toFixed(1)}s`}
pinned={livePinned('analysis:last')}
onTogglePin={toggleLive('analysis:last')}
/>
)}
</PerfProbeMetricSection>
)}
{live.cover && (
<PerfProbeMetricSection title="Cover pipeline" defaultOpen={false}>
<PerfProbeMetricCard
label="Backfill (lib)"
value={live.cover.cachedPerMinute.toFixed(1)}
unit="cpm"
detail={live.cover.total > 0
? `${live.cover.done.toLocaleString()} / ${live.cover.total.toLocaleString()} cached`
: 'covers cached per minute'}
pinned={livePinned('cover:cpm')}
onTogglePin={toggleLive('cover:cpm')}
/>
<PerfProbeMetricCard
label="On-demand (ui)"
value={live.cover.uiPerMinute.toFixed(1)}
unit="cpm"
detail="UI cover ensures per minute"
pinned={livePinned('cover:cpm:ui')}
onTogglePin={toggleLive('cover:cpm:ui')}
/>
</PerfProbeMetricSection>
)}
</div>
);
}
@@ -0,0 +1,149 @@
import { useAuthStore } from '@/store/authStore';
import type { NavidromeAdminRole } from '@/hooks/useNavidromeAdminRole';
import { isNavidromeServer, formatServerSoftware } from '@/utils/server/subsonicServerIdentity';
import { FEATURE_AUDIOMUSE_SIMILAR_TRACKS, OP_SIMILAR_TRACKS } from '@/serverCapabilities/catalog';
import {
isFeatureActiveForServer,
resolveCallRoutesForServer,
resolveFeatureForServer,
} from '@/serverCapabilities/storeView';
import type { CapabilityStatus, FeatureTrust, ResolvedCapability } from '@/serverCapabilities/types';
import { PerfProbeMetricSection } from '@/features/sidebar/components/perfProbe/PerfProbeMetricCard';
import PerfProbeDetailList, { type PerfProbeDetailRow } from '@/features/sidebar/components/perfProbe/PerfProbeDetailList';
import PerfProbeStatusBadge, { type PerfProbeBadgeTone } from '@/features/sidebar/components/perfProbe/PerfProbeStatusBadge';
function detectionBadge(status: CapabilityStatus): { tone: PerfProbeBadgeTone; label: string } {
switch (status) {
case 'present': return { tone: 'ok', label: 'Detected' };
case 'absent': return { tone: 'muted', label: 'Not detected' };
case 'probing': return { tone: 'warn', label: 'Checking…' };
case 'error': return { tone: 'error', label: 'Probe failed' };
case 'unknown': return { tone: 'muted', label: 'Not probed yet' };
default: return { tone: 'muted', label: 'N/A' };
}
}
function strategyLabel(strategyId: string | null): string {
switch (strategyId) {
case 'opensubsonic.sonicSimilarity': return 'sonicSimilarity (OpenSubsonic)';
case 'subsonic.getSimilarSongs': return 'getSimilarSongs (legacy)';
default: return '—';
}
}
function trustBadge(trust: FeatureTrust | null): { tone: PerfProbeBadgeTone; label: string } {
switch (trust) {
case 'high': return { tone: 'ok', label: 'high' };
case 'low': return { tone: 'warn', label: 'heuristic' };
default: return { tone: 'muted', label: '—' };
}
}
function adminRoleBadge(role: NavidromeAdminRole): { tone: PerfProbeBadgeTone; label: string } {
switch (role) {
case 'admin': return { tone: 'ok', label: 'Admin' };
case 'user': return { tone: 'neutral', label: 'Standard user' };
case 'checking':
case 'idle': return { tone: 'warn', label: 'Checking…' };
case 'error': return { tone: 'error', label: 'Could not verify' };
case 'na':
default: return { tone: 'muted', label: 'N/A' };
}
}
interface Props {
adminRole?: NavidromeAdminRole;
}
export default function SidebarPerfProbeServerSection({ adminRole = 'na' }: Props) {
const activeServerId = useAuthStore(s => s.activeServerId);
const server = useAuthStore(s => s.servers.find(srv => srv.id === s.activeServerId));
const identity = useAuthStore(s =>
activeServerId ? s.subsonicServerIdentityByServer[activeServerId] : undefined,
);
// Subscribe to the probe maps so the resolver-derived rows re-render on probe updates.
useAuthStore(s => (activeServerId ? s.audiomusePluginProbeByServer[activeServerId] : undefined));
useAuthStore(s => (activeServerId ? s.instantMixProbeByServer[activeServerId] : undefined));
useAuthStore(s => (activeServerId ? s.audiomuseNavidromeByServer[activeServerId] : undefined));
if (!server) {
return (
<PerfProbeMetricSection title="Active server" defaultOpen layout="stack">
<div className="perf-monitor-empty perf-monitor-empty--inline">
No server configured.
</div>
</PerfProbeMetricSection>
);
}
const navidrome = isNavidromeServer(identity);
const role = adminRoleBadge(adminRole);
const resolved: ResolvedCapability | null = activeServerId
? resolveFeatureForServer(activeServerId, FEATURE_AUDIOMUSE_SIMILAR_TRACKS)
: null;
const audiomuseActive = activeServerId
? isFeatureActiveForServer(activeServerId, FEATURE_AUDIOMUSE_SIMILAR_TRACKS)
: false;
const routes = activeServerId
? resolveCallRoutesForServer(activeServerId, FEATURE_AUDIOMUSE_SIMILAR_TRACKS, OP_SIMILAR_TRACKS)
: [];
const rows: PerfProbeDetailRow[] = [
{ label: 'Name', value: server.name || server.url },
{ label: 'Profile URL', value: <code className="perf-server-dl__code">{server.url}</code> },
{ label: 'Subsonic server', value: formatServerSoftware(identity) ?? 'Unknown' },
{
label: 'OpenSubsonic',
value: identity?.openSubsonic
? <PerfProbeStatusBadge tone="ok">yes</PerfProbeStatusBadge>
: identity
? <PerfProbeStatusBadge tone="muted">no</PerfProbeStatusBadge>
: '—',
},
];
if (navidrome) {
rows.push({
label: 'Navidrome role',
value: <PerfProbeStatusBadge tone={role.tone}>{role.label}</PerfProbeStatusBadge>,
});
}
if (resolved && resolved.strategyId !== null && resolved.status !== 'ineligible') {
const detect = detectionBadge(resolved.status);
const trust = trustBadge(resolved.trust);
rows.push(
{
label: 'AudioMuse Instant Mix',
value: <PerfProbeStatusBadge tone={detect.tone}>{detect.label}</PerfProbeStatusBadge>,
},
{ label: 'Provider', value: <code className="perf-server-dl__code">{strategyLabel(resolved.strategyId)}</code> },
{
label: 'Detection trust',
value: <PerfProbeStatusBadge tone={trust.tone}>{trust.label}</PerfProbeStatusBadge>,
},
{
label: 'Mode',
value: audiomuseActive
? (
<PerfProbeStatusBadge tone="ok">
{resolved.activation === 'auto' ? 'active (auto)' : 'enabled in Settings'}
</PerfProbeStatusBadge>
)
: <PerfProbeStatusBadge tone="muted">off</PerfProbeStatusBadge>,
},
);
if (routes.length > 0) {
rows.push({
label: 'Call route',
value: <code className="perf-server-dl__code">{routes.map(r => r.endpoint).join(' → ')}</code>,
});
}
}
return (
<PerfProbeMetricSection title="Active server" defaultOpen layout="stack">
<PerfProbeDetailList rows={rows} />
</PerfProbeMetricSection>
);
}
@@ -0,0 +1,165 @@
import { useState, type CSSProperties } from 'react';
import { ChevronRight } from 'lucide-react';
import { setPerfProbeFlag, type PerfProbeFlags } from '@/utils/perf/perfFlags';
import type { PerfToggleEngineLeaf, PerfToggleLeaf, PerfToggleNode } from '@/utils/perf/perfProbeToggleTree';
import {
isPerfToggleEngineLeaf,
isPerfToggleGroup,
PERF_PROBE_TOGGLE_TREE,
} from '@/utils/perf/perfProbeToggleTree';
interface EngineProps {
hotCacheEnabled: boolean;
setHotCacheEnabled: (v: boolean) => void;
normalizationEngine: string;
setNormalizationEngine: (v: 'off' | 'loudness') => void;
loggingMode: string;
setLoggingMode: (v: 'off' | 'normal') => void;
}
interface Props extends EngineProps {
perfFlags: PerfProbeFlags;
}
function ToggleLeaf({
node,
perfFlags,
engineProps,
}: {
node: PerfToggleLeaf | PerfToggleEngineLeaf;
perfFlags: PerfProbeFlags;
engineProps: EngineProps;
}) {
if (isPerfToggleEngineLeaf(node)) {
const checked = node.engine === 'hotCache'
? !engineProps.hotCacheEnabled
: node.engine === 'normalization'
? engineProps.normalizationEngine === 'off'
: engineProps.loggingMode === 'off';
return (
<label className="perf-tree-leaf">
<input
type="checkbox"
checked={checked}
onChange={e => {
if (node.engine === 'hotCache') engineProps.setHotCacheEnabled(!e.target.checked);
else if (node.engine === 'normalization') {
engineProps.setNormalizationEngine(e.target.checked ? 'off' : 'loudness');
} else engineProps.setLoggingMode(e.target.checked ? 'off' : 'normal');
}}
/>
<span className="perf-tree-leaf__text">
<span className="perf-tree-leaf__label">{node.label}</span>
{node.description && (
<span className="perf-tree-leaf__desc">{node.description}</span>
)}
</span>
</label>
);
}
if ('flag' in node) {
return (
<label className="perf-tree-leaf">
<input
type="checkbox"
checked={perfFlags[node.flag]}
onChange={e => setPerfProbeFlag(node.flag, e.target.checked)}
/>
<span className="perf-tree-leaf__text">
<span className="perf-tree-leaf__label">{node.label}</span>
{node.description && (
<span className="perf-tree-leaf__desc">{node.description}</span>
)}
</span>
</label>
);
}
return null;
}
function ToggleTreeNode({
node,
depth,
perfFlags,
engineProps,
}: {
node: PerfToggleNode;
depth: number;
perfFlags: PerfProbeFlags;
engineProps: EngineProps;
}) {
const [open, setOpen] = useState(depth < 1);
if (isPerfToggleGroup(node)) {
return (
<div className="perf-tree-group" style={{ '--perf-tree-depth': depth } as CSSProperties}>
<button
type="button"
className="perf-tree-group__head"
onClick={() => setOpen(v => !v)}
aria-expanded={open}
>
<ChevronRight size={14} className={`perf-tree-group__chevron${open ? ' perf-tree-group__chevron--open' : ''}`} />
<span>{node.label}</span>
</button>
{open && (
<div className="perf-tree-group__body">
{node.children.map(child => (
<ToggleTreeNode
key={child.id}
node={child}
depth={depth + 1}
perfFlags={perfFlags}
engineProps={engineProps}
/>
))}
</div>
)}
</div>
);
}
return (
<div className="perf-tree-leaf-wrap" style={{ '--perf-tree-depth': depth } as CSSProperties}>
<ToggleLeaf node={node} perfFlags={perfFlags} engineProps={engineProps} />
</div>
);
}
export default function SidebarPerfProbeTogglesTab({
perfFlags,
hotCacheEnabled,
setHotCacheEnabled,
normalizationEngine,
setNormalizationEngine,
loggingMode,
setLoggingMode,
}: Props) {
const engineProps: EngineProps = {
hotCacheEnabled,
setHotCacheEnabled,
normalizationEngine,
setNormalizationEngine,
loggingMode,
setLoggingMode,
};
return (
<div className="perf-toggle-tree">
<p className="sidebar-perf-modal__hint perf-toggle-tree__hint">
Disable subsystems one at a time to estimate their UI cost. Changes apply immediately.
</p>
{PERF_PROBE_TOGGLE_TREE.map(group => (
<ToggleTreeNode
key={group.id}
node={group}
depth={0}
perfFlags={perfFlags}
engineProps={engineProps}
/>
))}
</div>
);
}
@@ -0,0 +1,12 @@
import PerfCoverThreadsControl from '@/features/sidebar/components/perfProbe/PerfCoverThreadsControl';
export default function SidebarPerfProbeTuningTab() {
return (
<div className="perf-tuning">
<p className="sidebar-perf-modal__hint perf-tuning__hint">
Live runtime knobs for experiments not persisted in Settings. Values reset on restart unless noted.
</p>
<PerfCoverThreadsControl />
</div>
);
}
@@ -0,0 +1,65 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
interface DropdownRect {
top: number;
left: number;
width: number;
}
interface Result {
libraryDropdownOpen: boolean;
setLibraryDropdownOpen: (open: boolean) => void;
dropdownRect: DropdownRect;
libraryTriggerRef: React.RefObject<HTMLButtonElement | null>;
}
export function useSidebarLibraryDropdown(): Result {
const [libraryDropdownOpen, setLibraryDropdownOpen] = useState(false);
const [dropdownRect, setDropdownRect] = useState<DropdownRect>({ top: 0, left: 0, width: 0 });
const libraryTriggerRef = useRef<HTMLButtonElement>(null);
const updateDropdownPosition = useCallback(() => {
const el = libraryTriggerRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
setDropdownRect({
top: r.bottom + 4,
left: r.left,
width: r.width,
});
}, []);
useLayoutEffect(() => {
if (!libraryDropdownOpen) return;
updateDropdownPosition();
const onWin = () => updateDropdownPosition();
window.addEventListener('resize', onWin);
window.addEventListener('scroll', onWin, true);
return () => {
window.removeEventListener('resize', onWin);
window.removeEventListener('scroll', onWin, true);
};
}, [libraryDropdownOpen, updateDropdownPosition]);
useEffect(() => {
if (!libraryDropdownOpen) return;
const onDown = (e: MouseEvent) => {
const t = e.target as Node;
if (libraryTriggerRef.current?.contains(t)) return;
const panel = document.querySelector('.nav-library-dropdown-panel');
if (panel?.contains(t)) return;
setLibraryDropdownOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setLibraryDropdownOpen(false);
};
document.addEventListener('mousedown', onDown);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDown);
document.removeEventListener('keydown', onKey);
};
}, [libraryDropdownOpen]);
return { libraryDropdownOpen, setLibraryDropdownOpen, dropdownRect, libraryTriggerRef };
}
@@ -0,0 +1,253 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import type { SidebarItemConfig } from '@/features/sidebar/store/sidebarStore';
import {
applySidebarReorderById,
isSidebarNavItemUserHideable,
type SidebarNavDropTarget,
} from '@/features/sidebar/utils/sidebarNavReorder';
import {
SIDEBAR_NAV_LONG_PRESS_MOVE_CANCEL_PX,
SIDEBAR_NAV_LONG_PRESS_MS,
isPointerOutsideAsideSidebar,
} from '@/features/sidebar/utils/sidebarHelpers';
interface NavDndState {
section: 'library' | 'system';
draggedId: string;
}
interface Args {
isCollapsed: boolean;
sidebarItemsRef: React.MutableRefObject<SidebarItemConfig[]>;
setSidebarItems: (items: SidebarItemConfig[]) => void;
}
interface Result {
navDnd: NavDndState | null;
navDropTarget: SidebarNavDropTarget | null;
navDndTrashHint: { x: number; y: number } | null;
suppressNavClickRef: React.MutableRefObject<boolean>;
handleNavRowPointerDown: (e: React.PointerEvent, section: 'library' | 'system', id: string) => void;
navDndRowClass: (section: 'library' | 'system', id: string) => string;
}
export function useSidebarNavDnd({
isCollapsed, sidebarItemsRef, setSidebarItems,
}: Args): Result {
const [navDnd, setNavDnd] = useState<NavDndState | null>(null);
const [navDropTarget, setNavDropTarget] = useState<SidebarNavDropTarget | null>(null);
const navDropTargetRef = useRef<SidebarNavDropTarget | null>(null);
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
// eslint-disable-next-line react-hooks/refs
navDropTargetRef.current = navDropTarget;
/** DOM timers are numeric; avoid NodeJS `Timeout` typing from `setTimeout`. */
const longPressTimersRef = useRef<Map<number, number>>(new Map());
const suppressNavClickRef = useRef(false);
const lastPointerDuringNavDndRef = useRef({ x: 0, y: 0 });
const [navDndTrashHint, setNavDndTrashHint] = useState<{ x: number; y: number } | null>(null);
useEffect(() => () => {
longPressTimersRef.current.forEach(t => window.clearTimeout(t));
longPressTimersRef.current.clear();
}, []);
useEffect(() => {
if (!navDnd) return;
const updateDropFromPoint = (clientX: number, clientY: number) => {
if (isPointerOutsideAsideSidebar(clientX, clientY)) {
navDropTargetRef.current = null;
setNavDropTarget(null);
return;
}
const rows = document.querySelectorAll<HTMLElement>('.sidebar [data-sidebar-nav-dnd-row]');
let target: SidebarNavDropTarget | null = null;
for (const row of rows) {
const section = row.dataset.sidebarSection as 'library' | 'system' | undefined;
if (section !== navDnd.section) continue;
const rect = row.getBoundingClientRect();
const id = row.dataset.sidebarId;
if (!id) continue;
if (clientY < rect.top + rect.height / 2) {
target = { id, before: true, section };
break;
}
target = { id, before: false, section };
}
navDropTargetRef.current = target;
setNavDropTarget(target);
};
const endDrag = (apply: boolean) => {
window.removeEventListener('pointermove', onMove, { capture: true });
window.removeEventListener('pointerup', onUp, true);
window.removeEventListener('pointercancel', onUp, true);
window.removeEventListener('keydown', onKey, true);
document.body.style.userSelect = '';
setNavDndTrashHint(null);
const currentDnd = navDnd;
const drop = navDropTargetRef.current;
setNavDnd(null);
setNavDropTarget(null);
navDropTargetRef.current = null;
if (!apply || !currentDnd) return;
const { x, y } = lastPointerDuringNavDndRef.current;
if (isPointerOutsideAsideSidebar(x, y)) {
const id = currentDnd.draggedId;
if (id && isSidebarNavItemUserHideable(id)) {
const nextItems: SidebarItemConfig[] = sidebarItemsRef.current.map(i =>
i.id === id ? { ...i, visible: false } : i,
);
setSidebarItems(nextItems);
suppressNavClickRef.current = true;
}
return;
}
const next = applySidebarReorderById(
sidebarItemsRef.current,
currentDnd.section,
currentDnd.draggedId,
drop,
);
if (next) {
setSidebarItems(next);
suppressNavClickRef.current = true;
}
};
const onMove = (e: PointerEvent) => {
e.preventDefault();
lastPointerDuringNavDndRef.current = { x: e.clientX, y: e.clientY };
const outside = isPointerOutsideAsideSidebar(e.clientX, e.clientY);
const draggedId = navDnd.draggedId;
const canTrash = Boolean(draggedId && isSidebarNavItemUserHideable(draggedId));
if (outside && canTrash) {
setNavDndTrashHint({ x: e.clientX, y: e.clientY });
} else {
setNavDndTrashHint(null);
}
updateDropFromPoint(e.clientX, e.clientY);
};
const onUp = (e: PointerEvent) => {
lastPointerDuringNavDndRef.current = { x: e.clientX, y: e.clientY };
suppressNavClickRef.current = true;
endDrag(true);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
endDrag(false);
}
};
document.body.style.userSelect = 'none';
window.addEventListener('pointermove', onMove, { capture: true, passive: false });
window.addEventListener('pointerup', onUp, true);
window.addEventListener('pointercancel', onUp, true);
window.addEventListener('keydown', onKey, true);
return () => {
window.removeEventListener('pointermove', onMove, { capture: true });
window.removeEventListener('pointerup', onUp, true);
window.removeEventListener('pointercancel', onUp, true);
window.removeEventListener('keydown', onKey, true);
document.body.style.userSelect = '';
setNavDndTrashHint(null);
};
}, [navDnd, setSidebarItems, sidebarItemsRef]);
const handleNavRowPointerDown = useCallback(
(e: React.PointerEvent, section: 'library' | 'system', id: string) => {
if (isCollapsed || navDnd) return;
if (e.pointerType === 'mouse' && e.button !== 0) return;
const pid = e.pointerId;
const sx = e.clientX;
const sy = e.clientY;
let cleaned = false;
const cleanupEarly = () => {
if (cleaned) return;
cleaned = true;
document.removeEventListener('pointermove', onEarlyMove);
document.removeEventListener('pointerup', onEarlyUp, true);
document.removeEventListener('pointercancel', onEarlyUp, true);
};
const onEarlyMove = (ev: PointerEvent) => {
if (ev.pointerId !== pid) return;
if (Math.hypot(ev.clientX - sx, ev.clientY - sy) > SIDEBAR_NAV_LONG_PRESS_MOVE_CANCEL_PX) {
const t = longPressTimersRef.current.get(pid);
if (t != null) window.clearTimeout(t);
longPressTimersRef.current.delete(pid);
cleanupEarly();
}
};
const onEarlyUp = (ev: PointerEvent) => {
if (ev.pointerId !== pid) return;
const t = longPressTimersRef.current.get(pid);
if (t != null) window.clearTimeout(t);
longPressTimersRef.current.delete(pid);
cleanupEarly();
};
const timer = window.setTimeout(() => {
longPressTimersRef.current.delete(pid);
cleanupEarly();
window.getSelection()?.removeAllRanges();
lastPointerDuringNavDndRef.current = { x: sx, y: sy };
setNavDnd({ section, draggedId: id });
navDropTargetRef.current = { id, before: true, section };
setNavDropTarget({ id, before: true, section });
try {
(e.currentTarget as HTMLElement).setPointerCapture(pid);
} catch {
/* ignore */
}
}, SIDEBAR_NAV_LONG_PRESS_MS) as unknown as number;
longPressTimersRef.current.set(pid, timer);
document.addEventListener('pointermove', onEarlyMove);
document.addEventListener('pointerup', onEarlyUp, true);
document.addEventListener('pointercancel', onEarlyUp, true);
},
[isCollapsed, navDnd],
);
const navDndRowClass = useCallback(
(section: 'library' | 'system', id: string) => {
const dragging = navDnd?.section === section && navDnd.draggedId === id;
let drop = '';
if (
navDnd &&
navDropTarget?.section === section &&
navDropTarget.id === id &&
!(navDnd.section === section && navDnd.draggedId === id)
) {
drop = navDropTarget.before
? 'sidebar-nav-dnd-row--drop-before'
: 'sidebar-nav-dnd-row--drop-after';
}
return `sidebar-nav-dnd-row${dragging ? ' sidebar-nav-dnd-row--dragging' : ''}${drop ? ` ${drop}` : ''}`.trim();
},
[navDnd, navDropTarget],
);
return {
navDnd,
navDropTarget,
navDndTrashHint,
suppressNavClickRef,
handleNavRowPointerDown,
navDndRowClass,
};
}
@@ -0,0 +1,172 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { getAlbumList } from '@/api/subsonicLibrary';
import { isActiveServerReachable } from '@/utils/network/activeServerReachability';
import {
NEW_RELEASES_RESET_DELAY_MS,
NEW_RELEASES_SEEN_MAX_IDS,
NEW_RELEASES_UNREAD_POLL_MS,
NEW_RELEASES_UNREAD_SAMPLE_SIZE,
NEW_RELEASES_UNREAD_STORAGE_PREFIX,
mergeSeenNewReleaseIdsCap,
} from '@/features/sidebar/utils/sidebarHelpers';
interface Args {
serverId: string;
filterId: string;
isLoggedIn: boolean;
pathname: string;
}
export function useSidebarNewReleasesUnread({ serverId, filterId, isLoggedIn, pathname }: Args): number {
const [newReleasesUnreadCount, setNewReleasesUnreadCount] = useState(0);
const newReleasesRefreshSeqRef = useRef(0);
const newReleasesPageEnteredAtRef = useRef<number | null>(null);
const newReleasesResetTimerRef = useRef<number | null>(null);
const newReleasesSeenStorageKey = useMemo(
() => `${NEW_RELEASES_UNREAD_STORAGE_PREFIX}:${serverId || 'no-server'}:${filterId || 'all'}`,
[serverId, filterId],
);
const newReleasesSeenAllScopeStorageKey = useMemo(
() => `${NEW_RELEASES_UNREAD_STORAGE_PREFIX}:${serverId || 'no-server'}:all`,
[serverId],
);
const readSeenNewReleaseIdsByKey = useCallback((key: string): string[] => {
try {
const raw = localStorage.getItem(key);
if (!raw) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter((id): id is string => typeof id === 'string' && id.length > 0);
} catch {
return [];
}
}, []);
const readSeenNewReleaseIds = useCallback(
() => readSeenNewReleaseIdsByKey(newReleasesSeenStorageKey),
[newReleasesSeenStorageKey, readSeenNewReleaseIdsByKey],
);
const writeSeenNewReleaseIdsByKey = useCallback((key: string, ids: string[]) => {
const normalized = Array.from(new Set(ids.filter(Boolean))).slice(0, NEW_RELEASES_SEEN_MAX_IDS);
localStorage.setItem(key, JSON.stringify(normalized));
}, []);
const writeSeenNewReleaseIds = useCallback(
(ids: string[]) => writeSeenNewReleaseIdsByKey(newReleasesSeenStorageKey, ids),
[newReleasesSeenStorageKey, writeSeenNewReleaseIdsByKey],
);
const refreshNewReleasesUnread = useCallback(async (markAsSeen = false) => {
const seq = ++newReleasesRefreshSeqRef.current;
const isCurrent = () => seq === newReleasesRefreshSeqRef.current;
if (!isLoggedIn || !serverId || !isActiveServerReachable()) {
if (isCurrent()) setNewReleasesUnreadCount(0);
return;
}
try {
const newest = await getAlbumList('newest', NEW_RELEASES_UNREAD_SAMPLE_SIZE, 0);
const newestIds = newest.map(a => a.id).filter(Boolean);
let seenIds = readSeenNewReleaseIds();
// For a concrete library scope, bootstrap from the server-wide "all libraries"
// baseline when available, so switching scope doesn't hide existing unread.
if (seenIds.length === 0 && filterId !== 'all') {
const allScopeSeen = readSeenNewReleaseIdsByKey(newReleasesSeenAllScopeStorageKey);
if (allScopeSeen.length > 0) {
seenIds = allScopeSeen;
writeSeenNewReleaseIdsByKey(newReleasesSeenStorageKey, allScopeSeen);
}
}
if (seenIds.length === 0) {
// First bootstrap for this server/scope: baseline is "already seen".
writeSeenNewReleaseIds(newestIds);
if (isCurrent()) setNewReleasesUnreadCount(0);
return;
}
if (markAsSeen) {
// Prepend the live newest sample so a full `seenIds` list + slice(500)
// cannot silently discard freshly "read" albums (fixes badge coming back).
writeSeenNewReleaseIds(mergeSeenNewReleaseIdsCap(seenIds, newestIds, NEW_RELEASES_SEEN_MAX_IDS));
// Keep server-wide baseline in sync so scope fallback never resurrects
// already-viewed items after opening the New Releases page.
const allScopeSeen = readSeenNewReleaseIdsByKey(newReleasesSeenAllScopeStorageKey);
writeSeenNewReleaseIdsByKey(
newReleasesSeenAllScopeStorageKey,
mergeSeenNewReleaseIdsCap(allScopeSeen, newestIds, NEW_RELEASES_SEEN_MAX_IDS),
);
if (isCurrent()) setNewReleasesUnreadCount(0);
return;
}
const seenSet = new Set(seenIds);
const unread = newestIds.reduce((count, id) => count + (seenSet.has(id) ? 0 : 1), 0);
if (isCurrent()) setNewReleasesUnreadCount(unread);
} catch {
// Keep previous value on transient network/API errors.
}
}, [
filterId,
isLoggedIn,
newReleasesSeenAllScopeStorageKey,
newReleasesSeenStorageKey,
readSeenNewReleaseIds,
readSeenNewReleaseIdsByKey,
serverId,
writeSeenNewReleaseIds,
writeSeenNewReleaseIdsByKey,
]);
useEffect(() => {
const onNewReleasesPage = pathname.startsWith('/new-releases');
if (newReleasesResetTimerRef.current != null) {
window.clearTimeout(newReleasesResetTimerRef.current);
newReleasesResetTimerRef.current = null;
}
if (onNewReleasesPage) {
if (newReleasesPageEnteredAtRef.current == null) {
newReleasesPageEnteredAtRef.current = Date.now();
}
const elapsed = Date.now() - newReleasesPageEnteredAtRef.current;
const shouldMarkAsSeen = elapsed >= NEW_RELEASES_RESET_DELAY_MS;
void refreshNewReleasesUnread(shouldMarkAsSeen);
if (!shouldMarkAsSeen) {
const remaining = NEW_RELEASES_RESET_DELAY_MS - elapsed;
newReleasesResetTimerRef.current = window.setTimeout(() => {
newReleasesResetTimerRef.current = null;
void refreshNewReleasesUnread(true);
}, remaining);
}
} else {
newReleasesPageEnteredAtRef.current = null;
void refreshNewReleasesUnread(false);
}
const timer = window.setInterval(() => {
const activeOnNewReleases = pathname.startsWith('/new-releases');
const enteredAt = newReleasesPageEnteredAtRef.current;
const delayedSeenReached =
activeOnNewReleases &&
enteredAt != null &&
Date.now() - enteredAt >= NEW_RELEASES_RESET_DELAY_MS;
void refreshNewReleasesUnread(delayedSeenReached);
}, NEW_RELEASES_UNREAD_POLL_MS);
return () => {
window.clearInterval(timer);
if (newReleasesResetTimerRef.current != null) {
window.clearTimeout(newReleasesResetTimerRef.current);
newReleasesResetTimerRef.current = null;
}
};
}, [pathname, refreshNewReleasesUnread]);
return newReleasesUnreadCount;
}
@@ -0,0 +1,119 @@
import { useEffect, useState } from 'react';
import { acquirePerfLivePoll, patchPerfLiveAnalysis } from '@/utils/perf/perfLiveStore';
import { setPerfProbeTelemetryActive } from '@/utils/perf/perfTelemetry';
import { useAnalysisPerfLast } from '@/utils/perf/analysisPerfStore';
import { useAnalysisPerfListener } from '@/hooks/useAnalysisPerfListener';
import { useCoverPerfListener, useCoverUiThroughputPoll } from '@/hooks/useCoverPerfListener';
import {
getPerfProbeFlags,
subscribePerfProbeFlags,
} from '@/utils/perf/perfFlags';
import { hasAnyLiveMetricPollNeed, usePerfLiveOverlayPins } from '@/utils/perf/perfOverlayPins';
import { useSyncExternalStore } from 'react';
interface Result {
perfProbeOpen: boolean;
setPerfProbeOpen: (open: boolean) => void;
}
function useNeedAnalysisTelemetry(perfProbeOpen: boolean, livePins: ReadonlySet<string>): boolean {
return useSyncExternalStore(
subscribePerfProbeFlags,
() => {
const flags = getPerfProbeFlags();
return (
perfProbeOpen
|| flags.showAnalysisPerfOverlay
|| livePins.has('analysis:tpm')
|| livePins.has('analysis:last')
);
},
() => perfProbeOpen,
);
}
function useNeedCoverTelemetry(perfProbeOpen: boolean, livePins: ReadonlySet<string>): boolean {
return useSyncExternalStore(
subscribePerfProbeFlags,
() => (
perfProbeOpen
|| getPerfProbeFlags().showCoverPerfOverlay
|| livePins.has('cover:cpm')
|| livePins.has('cover:cpm:ui')
),
() => perfProbeOpen,
);
}
/** Wires Ctrl+Shift+D PsyLab modal and shared live metric polling. */
export function useSidebarPerfProbe(): Result {
const [perfProbeOpen, setPerfProbeOpen] = useState(false);
const livePins = usePerfLiveOverlayPins();
const analysisLast = useAnalysisPerfLast();
const needAnalysis = useNeedAnalysisTelemetry(perfProbeOpen, livePins);
const needCover = useNeedCoverTelemetry(perfProbeOpen, livePins);
useAnalysisPerfListener(needAnalysis);
useCoverPerfListener(needCover);
useCoverUiThroughputPoll(needCover);
useEffect(() => {
setPerfProbeTelemetryActive(perfProbeOpen);
return () => setPerfProbeTelemetryActive(false);
}, [perfProbeOpen]);
useEffect(() => {
if (!perfProbeOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setPerfProbeOpen(false);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [perfProbeOpen]);
useEffect(() => {
const releases: Array<() => void> = [];
if (perfProbeOpen) releases.push(acquirePerfLivePoll('modal'));
if (hasAnyLiveMetricPollNeed()) releases.push(acquirePerfLivePoll('overlay-pins'));
if (releases.length === 0) return;
return () => releases.forEach(release => release());
}, [perfProbeOpen, livePins.size]);
useEffect(() => {
patchPerfLiveAnalysis({
lastTotalMs: analysisLast?.totalMs ?? null,
lastFetchMs: analysisLast?.fetchMs ?? null,
lastSeedMs: analysisLast?.seedMs ?? null,
lastBpmMs: analysisLast?.bpmMs ?? null,
});
}, [
analysisLast?.at,
analysisLast?.totalMs,
analysisLast?.fetchMs,
analysisLast?.seedMs,
analysisLast?.bpmMs,
]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (!(e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey)) return;
if (e.key.toLowerCase() !== 'd') return;
const target = e.target as HTMLElement | null;
if (target && (
target.tagName === 'INPUT'
|| target.tagName === 'TEXTAREA'
|| target.tagName === 'SELECT'
|| target.isContentEditable
)) return;
e.preventDefault();
setPerfProbeOpen(true);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
return {
perfProbeOpen,
setPerfProbeOpen,
};
}
@@ -0,0 +1,28 @@
import { useEffect, useState } from 'react';
/** Adds .is-scrolling-on-scroll cue to the sidebar viewport for 180ms. */
export function useSidebarScrollVisible(sidebarViewportEl: HTMLDivElement | null): boolean {
const [isSidebarScrolling, setIsSidebarScrolling] = useState(false);
useEffect(() => {
if (!sidebarViewportEl) return;
let hideTimer: number | null = null;
const onScroll = () => {
setIsSidebarScrolling(true);
if (hideTimer != null) window.clearTimeout(hideTimer);
hideTimer = window.setTimeout(() => {
setIsSidebarScrolling(false);
hideTimer = null;
}, 180);
};
sidebarViewportEl.addEventListener('scroll', onScroll, { passive: true });
return () => {
sidebarViewportEl.removeEventListener('scroll', onScroll);
if (hideTimer != null) window.clearTimeout(hideTimer);
};
}, [sidebarViewportEl]);
return isSidebarScrolling;
}
+9
View File
@@ -0,0 +1,9 @@
/**
* Sidebar feature — the primary nav sidebar (library/system nav, playlists
* section, library picker, active jobs) plus its nav config store, reorder
* helpers, and the in-sidebar performance-probe overlay (Ctrl+Shift+P).
*/
export { default } from './components/Sidebar';
export { useSidebarStore, CONSERVED_SIDEBAR_NAV_IDS } from './store/sidebarStore';
export type { SidebarItemConfig } from './store/sidebarStore';
export { applySidebarReorderById, resolveStartRoute } from './utils/sidebarNavReorder';
@@ -0,0 +1,72 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export interface SidebarItemConfig {
id: string;
visible: boolean;
}
/** Kept in nav/routes but hidden from sidebar and visibility settings. */
export const CONSERVED_SIDEBAR_NAV_IDS = new Set<string>(['losslessAlbums']);
// All configurable nav items in their default order.
// Fixed items (nowPlaying, settings, offline) are not listed here.
export const DEFAULT_SIDEBAR_ITEMS: SidebarItemConfig[] = [
{ id: 'mainstage', visible: true },
{ id: 'newReleases', visible: true },
{ id: 'allAlbums', visible: true },
{ id: 'tracks', visible: true },
{ id: 'randomPicker', visible: true },
{ id: 'randomMix', visible: true },
{ id: 'randomAlbums', visible: true },
{ id: 'luckyMix', visible: true },
{ id: 'artists', visible: true },
{ id: 'composers', visible: false },
{ id: 'genres', visible: true },
{ id: 'favorites', visible: true },
{ id: 'playlists', visible: true },
{ id: 'mostPlayed', visible: true },
{ id: 'losslessAlbums',visible: false },
{ id: 'radio', visible: true },
{ id: 'folderBrowser', visible: false },
{ id: 'deviceSync', visible: false },
{ id: 'statistics', visible: true },
{ id: 'help', visible: true },
];
interface SidebarStore {
items: SidebarItemConfig[];
setItems: (items: SidebarItemConfig[]) => void;
toggleItem: (id: string) => void;
reset: () => void;
}
export const useSidebarStore = create<SidebarStore>()(
persist(
(set) => ({
items: DEFAULT_SIDEBAR_ITEMS,
setItems: (items) => set({ items }),
toggleItem: (id) => set((s) => ({
items: s.items.map(item => item.id === id ? { ...item, visible: !item.visible } : item),
})),
reset: () => set({ items: DEFAULT_SIDEBAR_ITEMS }),
}),
{
name: 'psysonic_sidebar',
onRehydrateStorage: () => (state) => {
if (!state) return;
// Sanitize: remove any null/corrupted entries that may have been persisted
const safe = (state.items ?? []).filter((i): i is SidebarItemConfig => i != null && typeof i.id === 'string');
const known = new Set(safe.map(i => i.id));
const missing = DEFAULT_SIDEBAR_ITEMS.filter(i => !known.has(i.id));
const merged = missing.length > 0 ? [...safe, ...missing] : safe;
state.items = merged.map(item =>
CONSERVED_SIDEBAR_NAV_IDS.has(item.id) ? { ...item, visible: false } : item,
);
},
}
)
);
@@ -0,0 +1,44 @@
export const SIDEBAR_NAV_LONG_PRESS_MS = 1000;
export const SIDEBAR_NAV_LONG_PRESS_MOVE_CANCEL_PX = 10;
export const SMART_PREFIX = 'psy-smart-';
export const NEW_RELEASES_UNREAD_STORAGE_PREFIX = 'psy_new_releases_unread_seen_v1';
export const NEW_RELEASES_UNREAD_SAMPLE_SIZE = 80;
export const NEW_RELEASES_UNREAD_POLL_MS = 2 * 60 * 1000;
export const NEW_RELEASES_RESET_DELAY_MS = 5_000;
/** Max album ids persisted per server/scope; cap must not drop the latest "newest" batch when marking read. */
export const NEW_RELEASES_SEEN_MAX_IDS = 500;
/** Merge previous seen IDs with the current `getAlbumList(newest)` sample: newest batch is kept in full first, then older seen until `maxIds` (localStorage budget). */
export function mergeSeenNewReleaseIdsCap(prevSeen: string[], newestBatch: string[], maxIds: number): string[] {
const out: string[] = [];
const seen = new Set<string>();
for (const id of newestBatch) {
if (!id || seen.has(id)) continue;
seen.add(id);
out.push(id);
}
for (const id of prevSeen) {
if (out.length >= maxIds) break;
if (!id || seen.has(id)) continue;
seen.add(id);
out.push(id);
}
return out;
}
export function isSmartPlaylistName(name: string): boolean {
return (name ?? '').toLowerCase().startsWith(SMART_PREFIX);
}
export function displayPlaylistName(name: string): string {
const n = name ?? '';
if (isSmartPlaylistName(n)) return n.slice(SMART_PREFIX.length);
return n;
}
export function isPointerOutsideAsideSidebar(clientX: number, clientY: number): boolean {
const aside = document.querySelector('aside.sidebar');
if (!aside) return false;
const r = aside.getBoundingClientRect();
return clientX < r.left || clientX > r.right || clientY < r.top || clientY > r.bottom;
}
@@ -0,0 +1,143 @@
import { describe, expect, it } from 'vitest';
import { applySidebarReorderById, resolveStartRoute } from '@/features/sidebar/utils/sidebarNavReorder';
import { DEFAULT_SIDEBAR_ITEMS, type SidebarItemConfig } from '@/features/sidebar/store/sidebarStore';
const hide = (items: SidebarItemConfig[], ...ids: string[]): SidebarItemConfig[] =>
items.map(i => (ids.includes(i.id) ? { ...i, visible: false } : i));
const only = (...ids: string[]): SidebarItemConfig[] =>
DEFAULT_SIDEBAR_ITEMS.map(i => ({ ...i, visible: ids.includes(i.id) }));
const ids = (items: SidebarItemConfig[]): string[] => items.map(i => i.id);
describe('applySidebarReorderById', () => {
it('moves a library item to before the target by id', () => {
// Default order has artists(8) then composers, genres(10). Move genres up.
const next = applySidebarReorderById(
DEFAULT_SIDEBAR_ITEMS, 'library', 'genres',
{ id: 'artists', before: true, section: 'library' },
);
expect(next).not.toBeNull();
const order = ids(next!);
expect(order.indexOf('genres')).toBe(order.indexOf('artists') - 1);
expect(next!.length).toBe(DEFAULT_SIDEBAR_ITEMS.length);
});
it('moves a library item to after the target by id', () => {
const next = applySidebarReorderById(
DEFAULT_SIDEBAR_ITEMS, 'library', 'artists',
{ id: 'genres', before: false, section: 'library' },
);
const order = ids(next!);
expect(order.indexOf('artists')).toBe(order.indexOf('genres') + 1);
});
it('reorders correctly even when a hidden/gated item sits between the rows', () => {
// The #1164 class: luckyMix is present in the stored list but hidden from
// render. An index-based reorder desynced here; the id-based one must not.
// Move genres to just before artists; luckyMix must keep its absolute slot.
const luckyMixIdx = DEFAULT_SIDEBAR_ITEMS.findIndex(i => i.id === 'luckyMix');
const next = applySidebarReorderById(
DEFAULT_SIDEBAR_ITEMS, 'library', 'genres',
{ id: 'artists', before: true, section: 'library' },
);
const order = ids(next!);
expect(order.indexOf('genres')).toBe(order.indexOf('artists') - 1);
expect(order.indexOf('luckyMix')).toBe(luckyMixIdx); // untouched anchor
});
it('returns null on an unknown dragged id (defensive guard)', () => {
expect(applySidebarReorderById(
DEFAULT_SIDEBAR_ITEMS, 'library', 'does-not-exist',
{ id: 'artists', before: true, section: 'library' },
)).toBeNull();
});
it('returns null on an unknown target id (defensive guard)', () => {
expect(applySidebarReorderById(
DEFAULT_SIDEBAR_ITEMS, 'library', 'genres',
{ id: 'does-not-exist', before: true, section: 'library' },
)).toBeNull();
});
it('returns null when the section does not match the drop target', () => {
expect(applySidebarReorderById(
DEFAULT_SIDEBAR_ITEMS, 'library', 'genres',
{ id: 'statistics', before: true, section: 'system' },
)).toBeNull();
});
it('returns null when an id does not belong to the claimed section', () => {
// 'statistics' is a system item — cannot be reordered as a library row.
expect(applySidebarReorderById(
DEFAULT_SIDEBAR_ITEMS, 'system', 'statistics',
{ id: 'genres', before: true, section: 'system' },
)).toBeNull();
});
it('returns null for a conserved (non-reorderable) id', () => {
expect(applySidebarReorderById(
DEFAULT_SIDEBAR_ITEMS, 'library', 'losslessAlbums',
{ id: 'genres', before: true, section: 'library' },
)).toBeNull();
});
it('returns null on a no-op drop (onto itself or its own edge)', () => {
expect(applySidebarReorderById(
DEFAULT_SIDEBAR_ITEMS, 'library', 'artists',
{ id: 'artists', before: true, section: 'library' },
)).toBeNull();
// Dropping artists before composers — the row right after it — changes nothing.
expect(applySidebarReorderById(
DEFAULT_SIDEBAR_ITEMS, 'library', 'artists',
{ id: 'composers', before: true, section: 'library' },
)).toBeNull();
});
it('does not mutate the input array', () => {
const snapshot = ids(DEFAULT_SIDEBAR_ITEMS);
applySidebarReorderById(
DEFAULT_SIDEBAR_ITEMS, 'library', 'genres',
{ id: 'artists', before: true, section: 'library' },
);
expect(ids(DEFAULT_SIDEBAR_ITEMS)).toEqual(snapshot);
});
});
describe('resolveStartRoute', () => {
it('falls back to the first visible library item when Mainstage is hidden', () => {
const items = hide(DEFAULT_SIDEBAR_ITEMS, 'mainstage');
expect(resolveStartRoute(items, 'hub', false)).toBe('/new-releases');
});
it('skips Mainstage ("/") even if it is still flagged visible', () => {
// Resolver is only consulted when Mainstage is hidden, but it must never
// hand back "/" — that would redirect the index route onto itself.
expect(resolveStartRoute(DEFAULT_SIDEBAR_ITEMS, 'hub', false)).toBe('/new-releases');
});
it('returns null when no library item is visible (caller renders empty Mainstage)', () => {
const items = DEFAULT_SIDEBAR_ITEMS.map(i => ({ ...i, visible: false }));
expect(resolveStartRoute(items, 'hub', false)).toBeNull();
});
it('honours sidebar order — first visible entry wins', () => {
expect(resolveStartRoute(only('favorites', 'artists'), 'hub', false)).toBe('/artists');
expect(resolveStartRoute(only('favorites'), 'hub', false)).toBe('/favorites');
});
it('skips luckyMix when it is not available', () => {
const items = only('luckyMix', 'genres');
// separate mode surfaces luckyMix, but availability gate is off → next item
expect(resolveStartRoute(items, 'separate', false)).toBe('/genres');
expect(resolveStartRoute(items, 'separate', true)).toBe('/lucky-mix');
});
it('respects randomNavMode hub/separate gating', () => {
// randomPicker (Build a Mix) only exists in hub mode; randomMix only in separate.
expect(resolveStartRoute(only('randomPicker'), 'hub', false)).toBe('/random');
expect(resolveStartRoute(only('randomPicker'), 'separate', false)).toBeNull();
expect(resolveStartRoute(only('randomMix'), 'separate', false)).toBe('/random/mix');
expect(resolveStartRoute(only('randomMix'), 'hub', false)).toBeNull();
});
});
@@ -0,0 +1,96 @@
import { ALL_NAV_ITEMS } from '@/config/navItems';
import { CONSERVED_SIDEBAR_NAV_IDS, type SidebarItemConfig } from '@/features/sidebar/store/sidebarStore';
import { applyListReorderById, type ListReorderDropTarget } from '@/utils/componentHelpers/listReorder';
export type SidebarNavSection = 'library' | 'system';
export type SidebarNavDropTarget = {
/** Stable id of the row the cursor is over — never a positional index. */
id: string;
before: boolean;
section: SidebarNavSection;
};
/** True when `id` is a real, non-conserved nav item that lives in `section`. */
function itemBelongsToSection(id: string, section: SidebarNavSection): boolean {
if (CONSERVED_SIDEBAR_NAV_IDS.has(id)) return false;
return ALL_NAV_ITEMS[id]?.section === section;
}
export function getLibraryItemsForReorder(
items: SidebarItemConfig[],
randomNavMode: 'hub' | 'separate',
): SidebarItemConfig[] {
return items.filter(cfg => {
if (CONSERVED_SIDEBAR_NAV_IDS.has(cfg.id)) return false;
if (!ALL_NAV_ITEMS[cfg.id] || ALL_NAV_ITEMS[cfg.id].section !== 'library') return false;
if (randomNavMode === 'hub' && (cfg.id === 'randomMix' || cfg.id === 'randomAlbums' || cfg.id === 'luckyMix')) return false;
if (randomNavMode === 'separate' && cfg.id === 'randomPicker') return false;
return true;
});
}
export function getSystemItemsForReorder(items: SidebarItemConfig[]): SidebarItemConfig[] {
return items.filter(cfg => ALL_NAV_ITEMS[cfg.id]?.section === 'system');
}
/**
* Resolve the route the app should open on "/" when the Mainstage entry is
* hidden from the sidebar. Mirrors the sidebar's own visible-library ordering
* (same filter + randomNavMode + luckyMix gating) and returns the first visible
* library item's route, skipping Mainstage itself ('/'). Returns null when no
* other library item is visible, so the caller can fall back to rendering the
* (empty) Mainstage rather than redirecting nowhere.
*/
export function resolveStartRoute(
items: SidebarItemConfig[],
randomNavMode: 'hub' | 'separate',
luckyMixAvailable: boolean,
): string | null {
const libraryConfigs = getLibraryItemsForReorder(items, randomNavMode).filter(cfg => {
if (!cfg.visible) return false;
if (cfg.id === 'luckyMix' && !luckyMixAvailable) return false;
return true;
});
for (const cfg of libraryConfigs) {
const to = ALL_NAV_ITEMS[cfg.id]?.to;
if (to && to !== '/') return to;
}
return null;
}
/** Same entries as in Settings toggles — safe to hide via drag-out. */
export function isSidebarNavItemUserHideable(id: string): boolean {
return Boolean(ALL_NAV_ITEMS[id]) && !CONSERVED_SIDEBAR_NAV_IDS.has(id);
}
/**
* Reorders one sidebar section by **stable item id**, not by positional index.
*
* The dragged row and the drop target are identified by id, and the move is
* applied directly to the canonical full `items` array. This deliberately has
* no shared index space with whatever filter decides which rows are *shown*:
* a render filter (visibility, luckyMix availability, randomNavMode gating, any
* future gate) can never desync the reorder, because indices are resolved here
* from ids against the same array that is mutated. Hidden/gated items keep their
* absolute slot and are never anchors.
*
* Returns a new `items` array, or `null` when nothing should change — unknown
* id, cross-section drop, dropping onto self, or a no-op edge (defensive guard
* against any payload the canonical list does not contain).
*/
export function applySidebarReorderById(
allItems: SidebarItemConfig[],
section: SidebarNavSection,
draggedId: string,
target: ListReorderDropTarget | null,
): SidebarItemConfig[] | null {
if (!target || target.section !== section) return null;
// Guard: both ids must be real, non-conserved items that belong to `section`.
if (!itemBelongsToSection(draggedId, section)) return null;
if (!itemBelongsToSection(target.id, section)) return null;
// The move itself is the shared id-based reorder over the canonical array.
return applyListReorderById(allItems, draggedId, target);
}