mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
refactor(sidebar): co-locate sidebar feature into features/sidebar
This commit is contained in:
@@ -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 server’s 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user