mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
refactor(sidebar): H2 — extract 5 hooks + 5 sub-components (#664)
* refactor(sidebar): H2.1 — extract helpers + constants * refactor(sidebar): H2.2 — extract useSidebarNewReleasesUnread hook * refactor(sidebar): H2.3 — extract useSidebarNavDnd hook * refactor(sidebar): H2.4 — extract 3 hooks (LibraryDropdown + ScrollVisible + PerfProbe) * refactor(sidebar): H2.5 — extract SidebarPerfProbeModal component * refactor(sidebar): H2.5–H2.8 — split JSX into per-block components Sidebar.tsx 938 → 271 LOC. All resulting files under the 400-LOC guideline: components/sidebar/SidebarLibraryPicker.tsx 96 components/sidebar/SidebarActiveJobs.tsx 58 components/sidebar/SidebarNavBody.tsx 293 components/sidebar/SidebarPerfProbeModal.tsx 259 components/sidebar/SidebarPerfProbePhase2.tsx 176
This commit is contained in:
committed by
GitHub
parent
ef5eda263d
commit
b8a9fe860e
+80
-1298
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,58 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { HardDriveDownload, HardDriveUpload, X } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isCollapsed: boolean;
|
||||||
|
activeJobsCount: number;
|
||||||
|
cancelAllDownloads: () => void;
|
||||||
|
isSyncing: boolean;
|
||||||
|
syncJobDone: number;
|
||||||
|
syncJobSkip: number;
|
||||||
|
syncJobFail: number;
|
||||||
|
syncJobTotal: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SidebarActiveJobs({
|
||||||
|
isCollapsed, activeJobsCount, cancelAllDownloads,
|
||||||
|
isSyncing, syncJobDone, syncJobSkip, syncJobFail, syncJobTotal,
|
||||||
|
}: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{activeJobsCount > 0 && (
|
||||||
|
<div
|
||||||
|
className={`sidebar-offline-queue ${isCollapsed ? 'sidebar-offline-queue--collapsed' : ''}`}
|
||||||
|
data-tooltip={isCollapsed ? t('sidebar.downloadingTracks', { n: activeJobsCount }) : undefined}
|
||||||
|
data-tooltip-pos="right"
|
||||||
|
>
|
||||||
|
<HardDriveDownload size={isCollapsed ? 18 : 14} className="spin-slow" />
|
||||||
|
{!isCollapsed && (
|
||||||
|
<span>{t('sidebar.downloadingTracks', { n: activeJobsCount })}</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={isCollapsed ? t('sidebar.syncingTracks', { done: syncJobDone + syncJobSkip + syncJobFail, total: syncJobTotal }) : undefined}
|
||||||
|
data-tooltip-pos="right"
|
||||||
|
>
|
||||||
|
<HardDriveUpload size={isCollapsed ? 18 : 14} className="spin-slow" />
|
||||||
|
{!isCollapsed && (
|
||||||
|
<span>{t('sidebar.syncingTracks', { done: syncJobDone + syncJobSkip + syncJobFail, total: syncJobTotal })}</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,293 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { NavLink } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { AudioLines, ChevronRight, HardDriveDownload, PlayCircle, Settings, Sparkles } from 'lucide-react';
|
||||||
|
import type { SidebarItemConfig } from '../../store/sidebarStore';
|
||||||
|
import { ALL_NAV_ITEMS } from '../../config/navItems';
|
||||||
|
import WhatsNewBanner from '../WhatsNewBanner';
|
||||||
|
import { displayPlaylistName, isSmartPlaylistName } from '../../utils/sidebarHelpers';
|
||||||
|
import SidebarLibraryPicker from './SidebarLibraryPicker';
|
||||||
|
import SidebarActiveJobs from './SidebarActiveJobs';
|
||||||
|
|
||||||
|
interface NavDndState {
|
||||||
|
section: 'library' | 'system';
|
||||||
|
fromIdx: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
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[];
|
||||||
|
libraryItemsForReorder: SidebarItemConfig[];
|
||||||
|
visibleSystemConfigs: SidebarItemConfig[];
|
||||||
|
systemItemsForReorder: SidebarItemConfig[];
|
||||||
|
playlistsExpanded: boolean;
|
||||||
|
setPlaylistsExpanded: (v: boolean) => void;
|
||||||
|
playlists: { id: string; name: string }[];
|
||||||
|
playlistsLoading: boolean;
|
||||||
|
newReleasesUnreadCount: number;
|
||||||
|
navDnd: NavDndState | null;
|
||||||
|
navDndRowClass: (section: 'library' | 'system', sectionIdx: number) => string;
|
||||||
|
handleNavRowPointerDown: (e: React.PointerEvent, section: 'library' | 'system', sectionIdx: number) => void;
|
||||||
|
isPlaying: boolean;
|
||||||
|
hasNowPlayingTrack: boolean;
|
||||||
|
hasOfflineContent: boolean;
|
||||||
|
activeJobsCount: 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, libraryItemsForReorder,
|
||||||
|
visibleSystemConfigs, systemItemsForReorder,
|
||||||
|
playlistsExpanded, setPlaylistsExpanded, playlists, playlistsLoading,
|
||||||
|
newReleasesUnreadCount, navDnd, navDndRowClass, handleNavRowPointerDown,
|
||||||
|
isPlaying, hasNowPlayingTrack, hasOfflineContent,
|
||||||
|
activeJobsCount, cancelAllDownloads,
|
||||||
|
isSyncing, syncJobDone, syncJobSkip, syncJobFail, syncJobTotal,
|
||||||
|
} = props;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{!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 sectionIdx = libraryItemsForReorder.findIndex(x => x.id === cfg.id);
|
||||||
|
const dndRow = !isCollapsed && sectionIdx >= 0;
|
||||||
|
const rowClass = dndRow ? navDndRowClass('library', sectionIdx) : undefined;
|
||||||
|
const dndProps = dndRow
|
||||||
|
? {
|
||||||
|
'data-sidebar-nav-dnd-row': '',
|
||||||
|
'data-sidebar-section': 'library' as const,
|
||||||
|
'data-sidebar-idx': String(sectionIdx),
|
||||||
|
onPointerDown: (e: React.PointerEvent) =>
|
||||||
|
handleNavRowPointerDown(e, 'library', sectionIdx),
|
||||||
|
}
|
||||||
|
: {};
|
||||||
|
|
||||||
|
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 && (
|
||||||
|
<div className="sidebar-playlists-list">
|
||||||
|
{playlistsLoading ? (
|
||||||
|
<div className="sidebar-playlists-loading">
|
||||||
|
<div className="spinner" style={{ width: 14, height: 14 }} />
|
||||||
|
</div>
|
||||||
|
) : playlists.length === 0 ? (
|
||||||
|
<div className="sidebar-playlists-empty">{t('playlists.empty')}</div>
|
||||||
|
) : (
|
||||||
|
playlists.map((pl: { id: string; name: string }) => (
|
||||||
|
<NavLink
|
||||||
|
key={pl.id}
|
||||||
|
to={`/playlists/${pl.id}`}
|
||||||
|
className={({ isActive }) => `nav-link sidebar-playlist-item ${isActive ? 'active' : ''}`}
|
||||||
|
>
|
||||||
|
{isSmartPlaylistName(pl.name) ? <Sparkles size={12} /> : <PlayCircle size={12} />}
|
||||||
|
<span>{displayPlaylistName(pl.name)}</span>
|
||||||
|
</NavLink>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</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} />
|
||||||
|
|
||||||
|
{/* Now Playing — fixed, always visible */}
|
||||||
|
<NavLink
|
||||||
|
to="/now-playing"
|
||||||
|
className={({ isActive }) => `nav-link nav-link-nowplaying ${isActive ? 'active' : ''}`}
|
||||||
|
data-tooltip={isCollapsed ? t('sidebar.nowPlaying') : undefined}
|
||||||
|
data-tooltip-pos="bottom"
|
||||||
|
>
|
||||||
|
<span className="nav-np-icon-wrap">
|
||||||
|
<AudioLines size={isCollapsed ? 22 : 18} />
|
||||||
|
{isPlaying && hasNowPlayingTrack && <span className="nav-np-dot" />}
|
||||||
|
</span>
|
||||||
|
{!isCollapsed && <span>{t('sidebar.nowPlaying')}</span>}
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
|
{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 sectionIdx = systemItemsForReorder.findIndex(x => x.id === cfg.id);
|
||||||
|
const dndRow = !isCollapsed && sectionIdx >= 0;
|
||||||
|
const rowClass = dndRow ? navDndRowClass('system', sectionIdx) : undefined;
|
||||||
|
const dndProps = dndRow
|
||||||
|
? {
|
||||||
|
'data-sidebar-nav-dnd-row': '',
|
||||||
|
'data-sidebar-section': 'system' as const,
|
||||||
|
'data-sidebar-idx': String(sectionIdx),
|
||||||
|
onPointerDown: (e: React.PointerEvent) =>
|
||||||
|
handleNavRowPointerDown(e, 'system', sectionIdx),
|
||||||
|
}
|
||||||
|
: {};
|
||||||
|
|
||||||
|
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}
|
||||||
|
cancelAllDownloads={cancelAllDownloads}
|
||||||
|
isSyncing={isSyncing}
|
||||||
|
syncJobDone={syncJobDone}
|
||||||
|
syncJobSkip={syncJobSkip}
|
||||||
|
syncJobFail={syncJobFail}
|
||||||
|
syncJobTotal={syncJobTotal}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
import SidebarPerfProbePhase2 from './SidebarPerfProbePhase2';
|
||||||
|
import { resetPerfProbeFlags, setPerfProbeFlag, type PerfProbeFlags } from '../../utils/perfFlags';
|
||||||
|
|
||||||
|
interface PerfCpu {
|
||||||
|
app: number;
|
||||||
|
webkit: number;
|
||||||
|
supported: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PerfDiagRates {
|
||||||
|
progress: number;
|
||||||
|
waveform: number;
|
||||||
|
home: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
perfFlags: PerfProbeFlags;
|
||||||
|
perfCpu: PerfCpu | null;
|
||||||
|
perfDiagRates: PerfDiagRates | null;
|
||||||
|
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, perfCpu, perfDiagRates,
|
||||||
|
hotCacheEnabled, setHotCacheEnabled,
|
||||||
|
normalizationEngine, setNormalizationEngine,
|
||||||
|
loggingMode, setLoggingMode,
|
||||||
|
}: Props) {
|
||||||
|
if (!open) return null;
|
||||||
|
return createPortal(
|
||||||
|
<div className="modal-overlay modal-overlay--perf-probe" onClick={() => onClose()} role="dialog" aria-modal="true">
|
||||||
|
<div
|
||||||
|
className="modal-content sidebar-perf-modal"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
style={{ maxWidth: 560 }}
|
||||||
|
>
|
||||||
|
<button className="modal-close" onClick={() => onClose()}><X size={18} /></button>
|
||||||
|
<h3 className="modal-title">Performance Probe</h3>
|
||||||
|
<p className="sidebar-perf-modal__hint">
|
||||||
|
Temporary runtime switches to estimate UI effect cost.
|
||||||
|
</p>
|
||||||
|
<label className="sidebar-perf-modal__item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={perfFlags.showFpsOverlay}
|
||||||
|
onChange={e => setPerfProbeFlag('showFpsOverlay', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>Show FPS overlay (requestAnimationFrame rate)</span>
|
||||||
|
</label>
|
||||||
|
<div className="sidebar-perf-modal__cpu">
|
||||||
|
<div className="sidebar-perf-modal__cpu-title">Live CPU (approx)</div>
|
||||||
|
{perfCpu == null ? (
|
||||||
|
<div className="sidebar-perf-modal__cpu-row">Collecting samples…</div>
|
||||||
|
) : perfCpu.supported ? (
|
||||||
|
<>
|
||||||
|
<div className="sidebar-perf-modal__cpu-row">psysonic: {perfCpu.app.toFixed(1)}%</div>
|
||||||
|
<div className="sidebar-perf-modal__cpu-row">WebKitWebProcess: {perfCpu.webkit.toFixed(1)}%</div>
|
||||||
|
{perfDiagRates && (
|
||||||
|
<>
|
||||||
|
<div className="sidebar-perf-modal__cpu-row">audio:progress rate: {perfDiagRates.progress.toFixed(1)}/s</div>
|
||||||
|
<div className="sidebar-perf-modal__cpu-row">waveform draws rate: {perfDiagRates.waveform.toFixed(1)}/s</div>
|
||||||
|
<div className="sidebar-perf-modal__cpu-row">Home commits rate: {perfDiagRates.home.toFixed(1)}/s</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="sidebar-perf-modal__cpu-row">Unavailable on this platform/build.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<details className="sidebar-perf-modal__phase">
|
||||||
|
<summary className="sidebar-perf-modal__phase-title">Phase 1 — Global / Shell / Network</summary>
|
||||||
|
<label className="sidebar-perf-modal__item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={perfFlags.disableWaveformCanvas}
|
||||||
|
onChange={e => setPerfProbeFlag('disableWaveformCanvas', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>Disable only PlayerBar waveform (`WaveformSeek`)</span>
|
||||||
|
</label>
|
||||||
|
<label className="sidebar-perf-modal__item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={perfFlags.disablePlayerProgressUi}
|
||||||
|
onChange={e => setPerfProbeFlag('disablePlayerProgressUi', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>Disable player live progress UI updates (time + seek/progress bindings)</span>
|
||||||
|
</label>
|
||||||
|
<label className="sidebar-perf-modal__item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={perfFlags.disableMarqueeScroll}
|
||||||
|
onChange={e => setPerfProbeFlag('disableMarqueeScroll', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>Disable marquee text scrolling</span>
|
||||||
|
</label>
|
||||||
|
<label className="sidebar-perf-modal__item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={perfFlags.disableBackdropBlur}
|
||||||
|
onChange={e => setPerfProbeFlag('disableBackdropBlur', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>Disable backdrop blur effects</span>
|
||||||
|
</label>
|
||||||
|
<label className="sidebar-perf-modal__item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={perfFlags.disableCssAnimations}
|
||||||
|
onChange={e => setPerfProbeFlag('disableCssAnimations', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>Disable CSS animations and transitions</span>
|
||||||
|
</label>
|
||||||
|
<label className="sidebar-perf-modal__item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={perfFlags.disableOverlayScrollbars}
|
||||||
|
onChange={e => setPerfProbeFlag('disableOverlayScrollbars', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>Disable overlay scrollbar engine (JS + rail)</span>
|
||||||
|
</label>
|
||||||
|
<label className="sidebar-perf-modal__item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={perfFlags.disableTooltipPortal}
|
||||||
|
onChange={e => setPerfProbeFlag('disableTooltipPortal', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>Disable global tooltip portal/listeners</span>
|
||||||
|
</label>
|
||||||
|
<label className="sidebar-perf-modal__item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={perfFlags.disableQueuePanelMount}
|
||||||
|
onChange={e => setPerfProbeFlag('disableQueuePanelMount', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>Disable QueuePanel mount (desktop right column)</span>
|
||||||
|
</label>
|
||||||
|
<label className="sidebar-perf-modal__item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={perfFlags.disableBackgroundPolling}
|
||||||
|
onChange={e => setPerfProbeFlag('disableBackgroundPolling', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>Disable background polling (connection + radio metadata)</span>
|
||||||
|
</label>
|
||||||
|
<div className="sidebar-perf-modal__subhead">Engine/network toggles</div>
|
||||||
|
<label className="sidebar-perf-modal__item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!hotCacheEnabled}
|
||||||
|
onChange={e => setHotCacheEnabled(!e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>Disable hot-cache prefetch downloads</span>
|
||||||
|
</label>
|
||||||
|
<label className="sidebar-perf-modal__item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={normalizationEngine === 'off'}
|
||||||
|
onChange={e => setNormalizationEngine(e.target.checked ? 'off' : 'loudness')}
|
||||||
|
/>
|
||||||
|
<span>Disable normalization engine (set to Off)</span>
|
||||||
|
</label>
|
||||||
|
<label className="sidebar-perf-modal__item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={loggingMode === 'off'}
|
||||||
|
onChange={e => setLoggingMode(e.target.checked ? 'off' : 'normal')}
|
||||||
|
/>
|
||||||
|
<span>Set runtime logging mode to Off</span>
|
||||||
|
</label>
|
||||||
|
</details>
|
||||||
|
<SidebarPerfProbePhase2 perfFlags={perfFlags} />
|
||||||
|
<details className="sidebar-perf-modal__phase">
|
||||||
|
<summary className="sidebar-perf-modal__phase-title">Phase 3 — Active diagnostics (quick access)</summary>
|
||||||
|
<label className="sidebar-perf-modal__item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={perfFlags.disablePlayerProgressUi}
|
||||||
|
onChange={e => setPerfProbeFlag('disablePlayerProgressUi', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>Disable player live progress UI updates (time + seek/progress bindings)</span>
|
||||||
|
</label>
|
||||||
|
<label className="sidebar-perf-modal__item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={perfFlags.disableWaveformCanvas}
|
||||||
|
onChange={e => setPerfProbeFlag('disableWaveformCanvas', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>Disable only PlayerBar waveform (`WaveformSeek`)</span>
|
||||||
|
</label>
|
||||||
|
<label className="sidebar-perf-modal__item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={perfFlags.disableHomeRailArtwork}
|
||||||
|
onChange={e => setPerfProbeFlag('disableHomeRailArtwork', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>Disable artwork inside Home rows/rails only</span>
|
||||||
|
</label>
|
||||||
|
<label className="sidebar-perf-modal__item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={perfFlags.disableMainstageRailArtwork}
|
||||||
|
onChange={e => setPerfProbeFlag('disableMainstageRailArtwork', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>Disable artwork inside Home rows/rails</span>
|
||||||
|
</label>
|
||||||
|
<label className="sidebar-perf-modal__item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={perfFlags.disableMainstageRails}
|
||||||
|
onChange={e => setPerfProbeFlag('disableMainstageRails', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>Disable Home rows/rails (`AlbumRow` + `SongRail`)</span>
|
||||||
|
</label>
|
||||||
|
<label className="sidebar-perf-modal__item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={perfFlags.disableMainstageHeroBackdrop}
|
||||||
|
onChange={e => setPerfProbeFlag('disableMainstageHeroBackdrop', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>Disable Hero backdrop/crossfade only</span>
|
||||||
|
</label>
|
||||||
|
<label className="sidebar-perf-modal__item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={perfFlags.disableHomeArtworkFx}
|
||||||
|
onChange={e => setPerfProbeFlag('disableHomeArtworkFx', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>Keep artwork, disable Home card visual effects (hover/overlay/shadows)</span>
|
||||||
|
</label>
|
||||||
|
<label className="sidebar-perf-modal__item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={perfFlags.disableHomeArtworkClip}
|
||||||
|
onChange={e => setPerfProbeFlag('disableHomeArtworkClip', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>Diagnostic: flatten Home artwork clipping (no rounded corners/masks)</span>
|
||||||
|
</label>
|
||||||
|
</details>
|
||||||
|
<div className="sidebar-perf-modal__actions">
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={() => resetPerfProbeFlags()}>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-primary" onClick={() => onClose()}>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import { setPerfProbeFlag, type PerfProbeFlags } from '../../utils/perfFlags';
|
||||||
|
|
||||||
|
export default function SidebarPerfProbePhase2({ perfFlags }: { perfFlags: PerfProbeFlags }) {
|
||||||
|
return (
|
||||||
|
<details className="sidebar-perf-modal__phase">
|
||||||
|
<summary className="sidebar-perf-modal__phase-title">Phase 2 — Mainstage (Center Content)</summary>
|
||||||
|
<label className="sidebar-perf-modal__item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={perfFlags.disableMainRouteContentMount}
|
||||||
|
onChange={e => setPerfProbeFlag('disableMainRouteContentMount', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>Disable central route content mount</span>
|
||||||
|
</label>
|
||||||
|
<details className="sidebar-perf-modal__phase sidebar-perf-modal__phase--nested">
|
||||||
|
<summary className="sidebar-perf-modal__phase-title">Shared mainstage layers (multiple pages)</summary>
|
||||||
|
<label className="sidebar-perf-modal__item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={perfFlags.disableMainstageStickyHeader}
|
||||||
|
onChange={e => setPerfProbeFlag('disableMainstageStickyHeader', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>Disable sticky headers (Tracks + Albums)</span>
|
||||||
|
</label>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details className="sidebar-perf-modal__phase sidebar-perf-modal__phase--nested">
|
||||||
|
<summary className="sidebar-perf-modal__phase-title">Home (`/`)</summary>
|
||||||
|
<label className="sidebar-perf-modal__item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={perfFlags.disableMainstageHero}
|
||||||
|
onChange={e => setPerfProbeFlag('disableMainstageHero', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>Disable Home hero block</span>
|
||||||
|
</label>
|
||||||
|
<label className="sidebar-perf-modal__item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={perfFlags.disableMainstageHeroBackdrop}
|
||||||
|
onChange={e => setPerfProbeFlag('disableMainstageHeroBackdrop', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>Disable Hero backdrop/crossfade only</span>
|
||||||
|
</label>
|
||||||
|
<label className="sidebar-perf-modal__item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={perfFlags.disableMainstageRails}
|
||||||
|
onChange={e => setPerfProbeFlag('disableMainstageRails', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>Disable Home rows/rails (`AlbumRow` + `SongRail`)</span>
|
||||||
|
</label>
|
||||||
|
<label className="sidebar-perf-modal__item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={perfFlags.disableHomeAlbumRows}
|
||||||
|
onChange={e => setPerfProbeFlag('disableHomeAlbumRows', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>Disable Home `AlbumRow` sections only</span>
|
||||||
|
</label>
|
||||||
|
<label className="sidebar-perf-modal__item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={perfFlags.disableHomeSongRails}
|
||||||
|
onChange={e => setPerfProbeFlag('disableHomeSongRails', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>Disable Home `SongRail` sections only</span>
|
||||||
|
</label>
|
||||||
|
<label className="sidebar-perf-modal__item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={perfFlags.disableMainstageRailArtwork}
|
||||||
|
onChange={e => setPerfProbeFlag('disableMainstageRailArtwork', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>Disable artwork inside Home rows/rails</span>
|
||||||
|
</label>
|
||||||
|
<label className="sidebar-perf-modal__item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={perfFlags.disableHomeRailArtwork}
|
||||||
|
onChange={e => setPerfProbeFlag('disableHomeRailArtwork', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>Disable artwork inside Home rows/rails only</span>
|
||||||
|
</label>
|
||||||
|
<label className="sidebar-perf-modal__item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={perfFlags.disableHomeArtworkFx}
|
||||||
|
onChange={e => setPerfProbeFlag('disableHomeArtworkFx', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>Keep artwork, disable Home card visual effects (hover/overlay/shadows)</span>
|
||||||
|
</label>
|
||||||
|
<label className="sidebar-perf-modal__item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={perfFlags.disableHomeArtworkClip}
|
||||||
|
onChange={e => setPerfProbeFlag('disableHomeArtworkClip', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>Diagnostic: flatten Home artwork clipping (no rounded corners/masks)</span>
|
||||||
|
</label>
|
||||||
|
<label className="sidebar-perf-modal__item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={perfFlags.disableMainstageRailInteractivity}
|
||||||
|
onChange={e => setPerfProbeFlag('disableMainstageRailInteractivity', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>Disable Home rail scroll/nav handlers</span>
|
||||||
|
</label>
|
||||||
|
<label className="sidebar-perf-modal__item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={perfFlags.disableMainstageGridCards}
|
||||||
|
onChange={e => setPerfProbeFlag('disableMainstageGridCards', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>Disable Home discover artists chip-grid</span>
|
||||||
|
</label>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details className="sidebar-perf-modal__phase sidebar-perf-modal__phase--nested">
|
||||||
|
<summary className="sidebar-perf-modal__phase-title">Tracks (`/tracks`)</summary>
|
||||||
|
<label className="sidebar-perf-modal__item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={perfFlags.disableMainstageHero}
|
||||||
|
onChange={e => setPerfProbeFlag('disableMainstageHero', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>Disable Tracks hero block</span>
|
||||||
|
</label>
|
||||||
|
<label className="sidebar-perf-modal__item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={perfFlags.disableMainstageRails}
|
||||||
|
onChange={e => setPerfProbeFlag('disableMainstageRails', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>Disable Tracks rails (Highly Rated + Random)</span>
|
||||||
|
</label>
|
||||||
|
<label className="sidebar-perf-modal__item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={perfFlags.disableMainstageRailArtwork}
|
||||||
|
onChange={e => setPerfProbeFlag('disableMainstageRailArtwork', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>Disable artwork inside Tracks rails</span>
|
||||||
|
</label>
|
||||||
|
<label className="sidebar-perf-modal__item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={perfFlags.disableMainstageRailInteractivity}
|
||||||
|
onChange={e => setPerfProbeFlag('disableMainstageRailInteractivity', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>Disable Tracks rail scroll/nav handlers</span>
|
||||||
|
</label>
|
||||||
|
<label className="sidebar-perf-modal__item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={perfFlags.disableMainstageVirtualLists}
|
||||||
|
onChange={e => setPerfProbeFlag('disableMainstageVirtualLists', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>Disable Tracks virtual browse list (`VirtualSongList`)</span>
|
||||||
|
</label>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details className="sidebar-perf-modal__phase sidebar-perf-modal__phase--nested">
|
||||||
|
<summary className="sidebar-perf-modal__phase-title">Albums (`/albums`)</summary>
|
||||||
|
<label className="sidebar-perf-modal__item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={perfFlags.disableMainstageGridCards}
|
||||||
|
onChange={e => setPerfProbeFlag('disableMainstageGridCards', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>Disable Albums card grid (`AlbumCard` list)</span>
|
||||||
|
</label>
|
||||||
|
</details>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,263 @@
|
|||||||
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import type { SidebarItemConfig } from '../store/sidebarStore';
|
||||||
|
import {
|
||||||
|
applySidebarDropReorder,
|
||||||
|
getLibraryItemsForReorder,
|
||||||
|
getSystemItemsForReorder,
|
||||||
|
isSidebarNavItemUserHideable,
|
||||||
|
type SidebarNavDropTarget,
|
||||||
|
} from '../utils/sidebarNavReorder';
|
||||||
|
import {
|
||||||
|
SIDEBAR_NAV_LONG_PRESS_MOVE_CANCEL_PX,
|
||||||
|
SIDEBAR_NAV_LONG_PRESS_MS,
|
||||||
|
isPointerOutsideAsideSidebar,
|
||||||
|
} from '../utils/sidebarHelpers';
|
||||||
|
|
||||||
|
interface NavDndState {
|
||||||
|
section: 'library' | 'system';
|
||||||
|
fromIdx: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Args {
|
||||||
|
isCollapsed: boolean;
|
||||||
|
sidebarItemsRef: React.MutableRefObject<SidebarItemConfig[]>;
|
||||||
|
randomNavModeRef: React.MutableRefObject<'hub' | 'separate'>;
|
||||||
|
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', sectionIdx: number) => void;
|
||||||
|
navDndRowClass: (section: 'library' | 'system', sectionIdx: number) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSidebarNavDnd({
|
||||||
|
isCollapsed, sidebarItemsRef, randomNavModeRef, setSidebarItems,
|
||||||
|
}: Args): Result {
|
||||||
|
const [navDnd, setNavDnd] = useState<NavDndState | null>(null);
|
||||||
|
const [navDropTarget, setNavDropTarget] = useState<SidebarNavDropTarget | null>(null);
|
||||||
|
const navDropTargetRef = useRef<SidebarNavDropTarget | null>(null);
|
||||||
|
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 idx = Number(row.dataset.sidebarIdx);
|
||||||
|
if (Number.isNaN(idx)) continue;
|
||||||
|
if (clientY < rect.top + rect.height / 2) {
|
||||||
|
target = { idx, before: true, section };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
target = { idx, 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 sectionItems =
|
||||||
|
currentDnd.section === 'library'
|
||||||
|
? getLibraryItemsForReorder(sidebarItemsRef.current, randomNavModeRef.current)
|
||||||
|
: getSystemItemsForReorder(sidebarItemsRef.current);
|
||||||
|
const id = sectionItems[currentDnd.fromIdx]?.id;
|
||||||
|
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 = applySidebarDropReorder(
|
||||||
|
sidebarItemsRef.current,
|
||||||
|
currentDnd.section,
|
||||||
|
currentDnd.fromIdx,
|
||||||
|
drop,
|
||||||
|
randomNavModeRef.current,
|
||||||
|
);
|
||||||
|
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 sectionItems =
|
||||||
|
navDnd.section === 'library'
|
||||||
|
? getLibraryItemsForReorder(sidebarItemsRef.current, randomNavModeRef.current)
|
||||||
|
: getSystemItemsForReorder(sidebarItemsRef.current);
|
||||||
|
const draggedId = sectionItems[navDnd.fromIdx]?.id;
|
||||||
|
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, randomNavModeRef]);
|
||||||
|
|
||||||
|
const handleNavRowPointerDown = useCallback(
|
||||||
|
(e: React.PointerEvent, section: 'library' | 'system', sectionIdx: number) => {
|
||||||
|
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, fromIdx: sectionIdx });
|
||||||
|
navDropTargetRef.current = { idx: sectionIdx, before: true, section };
|
||||||
|
setNavDropTarget({ idx: sectionIdx, 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', sectionIdx: number) => {
|
||||||
|
const dragging = navDnd?.section === section && navDnd.fromIdx === sectionIdx;
|
||||||
|
let drop = '';
|
||||||
|
if (
|
||||||
|
navDnd &&
|
||||||
|
navDropTarget?.section === section &&
|
||||||
|
navDropTarget.idx === sectionIdx &&
|
||||||
|
!(navDnd.section === section && navDnd.fromIdx === sectionIdx)
|
||||||
|
) {
|
||||||
|
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,171 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { getAlbumList } from '../api/subsonicLibrary';
|
||||||
|
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 '../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) {
|
||||||
|
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,140 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { setPerfProbeTelemetryActive } from '../utils/perfTelemetry';
|
||||||
|
|
||||||
|
interface PerfCpu {
|
||||||
|
app: number;
|
||||||
|
webkit: number;
|
||||||
|
supported: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PerfDiagRates {
|
||||||
|
progress: number;
|
||||||
|
waveform: number;
|
||||||
|
home: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Result {
|
||||||
|
perfProbeOpen: boolean;
|
||||||
|
setPerfProbeOpen: (open: boolean) => void;
|
||||||
|
perfCpu: PerfCpu | null;
|
||||||
|
perfDiagRates: PerfDiagRates | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wires up Ctrl+Shift+D to open the perf probe; polls CPU + diag-rate counters
|
||||||
|
* every 2s while it is open. */
|
||||||
|
export function useSidebarPerfProbe(): Result {
|
||||||
|
const [perfProbeOpen, setPerfProbeOpen] = useState(false);
|
||||||
|
const [perfCpu, setPerfCpu] = useState<PerfCpu | null>(null);
|
||||||
|
const [perfDiagRates, setPerfDiagRates] = useState<PerfDiagRates | null>(null);
|
||||||
|
|
||||||
|
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(() => {
|
||||||
|
if (!perfProbeOpen) return;
|
||||||
|
type Snapshot = {
|
||||||
|
supported: boolean;
|
||||||
|
total_jiffies: number;
|
||||||
|
app_jiffies: number;
|
||||||
|
webkit_jiffies: number;
|
||||||
|
logical_cpus: number;
|
||||||
|
};
|
||||||
|
let cancelled = false;
|
||||||
|
let prev: Snapshot | null = null;
|
||||||
|
let prevCounters: { progress: number; waveform: number; home: number } | null = null;
|
||||||
|
let prevCountersAt = 0;
|
||||||
|
let timer: number | null = null;
|
||||||
|
const poll = async () => {
|
||||||
|
try {
|
||||||
|
const snap = await invoke<Snapshot>('performance_cpu_snapshot');
|
||||||
|
if (cancelled) return;
|
||||||
|
if (!snap.supported) {
|
||||||
|
setPerfCpu({ app: 0, webkit: 0, supported: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (prev) {
|
||||||
|
const totalDelta = snap.total_jiffies - prev.total_jiffies;
|
||||||
|
const appDelta = snap.app_jiffies - prev.app_jiffies;
|
||||||
|
const webkitDelta = snap.webkit_jiffies - prev.webkit_jiffies;
|
||||||
|
if (totalDelta > 0) {
|
||||||
|
const cpuScale = Math.max(1, snap.logical_cpus || 1) * 100;
|
||||||
|
const appPct = Math.max(0, Math.min(1000, (appDelta / totalDelta) * cpuScale));
|
||||||
|
const webkitPct = Math.max(0, Math.min(1000, (webkitDelta / totalDelta) * cpuScale));
|
||||||
|
setPerfCpu({
|
||||||
|
app: Number.isFinite(appPct) ? appPct : 0,
|
||||||
|
webkit: Number.isFinite(webkitPct) ? webkitPct : 0,
|
||||||
|
supported: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
const root = globalThis as unknown as { __psyPerfCounters?: Record<string, number> };
|
||||||
|
const counters = root.__psyPerfCounters ?? {};
|
||||||
|
const nextCounters = {
|
||||||
|
progress: counters.audioProgressEvents ?? 0,
|
||||||
|
waveform: counters.waveformDraws ?? 0,
|
||||||
|
home: counters.homeCommits ?? 0,
|
||||||
|
};
|
||||||
|
if (prevCounters && prevCountersAt > 0) {
|
||||||
|
const dt = Math.max(0.25, (now - prevCountersAt) / 1000);
|
||||||
|
setPerfDiagRates({
|
||||||
|
progress: (nextCounters.progress - prevCounters.progress) / dt,
|
||||||
|
waveform: (nextCounters.waveform - prevCounters.waveform) / dt,
|
||||||
|
home: (nextCounters.home - prevCounters.home) / dt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
prevCounters = nextCounters;
|
||||||
|
prevCountersAt = now;
|
||||||
|
prev = snap;
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setPerfCpu({ app: 0, webkit: 0, supported: false });
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) timer = window.setTimeout(poll, 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void poll();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (timer != null) window.clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [perfProbeOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!perfProbeOpen) {
|
||||||
|
setPerfCpu(null);
|
||||||
|
setPerfDiagRates(null);
|
||||||
|
}
|
||||||
|
}, [perfProbeOpen]);
|
||||||
|
|
||||||
|
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, perfCpu, perfDiagRates };
|
||||||
|
}
|
||||||
@@ -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,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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user