diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 043a203c..0a978532 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,82 +1,31 @@ -import { getPlaylists } from '../api/subsonicPlaylists'; -import { getAlbumList } from '../api/subsonicLibrary'; -import React, { useState, useRef, useLayoutEffect, useEffect, useCallback, useMemo } from 'react'; +import { useState, useRef, useEffect, useMemo } from 'react'; import { createPortal } from 'react-dom'; -import { invoke } from '@tauri-apps/api/core'; import { usePlayerStore } from '../store/playerStore'; import { useOfflineStore } from '../store/offlineStore'; import { useOfflineJobStore } from '../store/offlineJobStore'; import { useDeviceSyncJobStore } from '../store/deviceSyncJobStore'; import { useAuthStore } from '../store/authStore'; -import { useSidebarStore, type SidebarItemConfig } from '../store/sidebarStore'; -import { NavLink, useLocation } from 'react-router-dom'; +import { useSidebarStore } from '../store/sidebarStore'; +import { useLocation } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { - Settings, - PanelLeftClose, PanelLeft, AudioLines, HardDriveDownload, HardDriveUpload, - ChevronDown, Check, Music2, X, ChevronRight, PlayCircle, Sparkles, Trash2, -} from 'lucide-react'; +import { PanelLeft, PanelLeftClose, Trash2 } from 'lucide-react'; import PsysonicLogo from './PsysonicLogo'; import PSmallLogo from './PSmallLogo'; -import WhatsNewBanner from './WhatsNewBanner'; import { usePlaylistStore } from '../store/playlistStore'; -import { ALL_NAV_ITEMS } from '../config/navItems'; import OverlayScrollArea from './OverlayScrollArea'; import { - applySidebarDropReorder, getLibraryItemsForReorder, getSystemItemsForReorder, - isSidebarNavItemUserHideable, - type SidebarNavDropTarget, } from '../utils/sidebarNavReorder'; import { useLuckyMixAvailable } from '../hooks/useLuckyMixAvailable'; -import { resetPerfProbeFlags, setPerfProbeFlag, usePerfProbeFlags } from '../utils/perfFlags'; -import { setPerfProbeTelemetryActive } from '../utils/perfTelemetry'; - -const SIDEBAR_NAV_LONG_PRESS_MS = 1000; -const SIDEBAR_NAV_LONG_PRESS_MOVE_CANCEL_PX = 10; -const SMART_PREFIX = 'psy-smart-'; -const NEW_RELEASES_UNREAD_STORAGE_PREFIX = 'psy_new_releases_unread_seen_v1'; -const NEW_RELEASES_UNREAD_SAMPLE_SIZE = 80; -const NEW_RELEASES_UNREAD_POLL_MS = 2 * 60 * 1000; -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. */ -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). */ -function mergeSeenNewReleaseIdsCap(prevSeen: string[], newestBatch: string[], maxIds: number): string[] { - const out: string[] = []; - const seen = new Set(); - 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; -} - -function isSmartPlaylistName(name: string): boolean { - return (name ?? '').toLowerCase().startsWith(SMART_PREFIX); -} - -function displayPlaylistName(name: string): string { - const n = name ?? ''; - if (isSmartPlaylistName(n)) return n.slice(SMART_PREFIX.length); - return n; -} - -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; -} +import { usePerfProbeFlags } from '../utils/perfFlags'; +import { useSidebarNewReleasesUnread } from '../hooks/useSidebarNewReleasesUnread'; +import { useSidebarNavDnd } from '../hooks/useSidebarNavDnd'; +import { useSidebarLibraryDropdown } from '../hooks/useSidebarLibraryDropdown'; +import { useSidebarScrollVisible } from '../hooks/useSidebarScrollVisible'; +import { useSidebarPerfProbe } from '../hooks/useSidebarPerfProbe'; +import SidebarPerfProbeModal from './sidebar/SidebarPerfProbeModal'; +import SidebarNavBody from './sidebar/SidebarNavBody'; export default function Sidebar({ @@ -119,7 +68,8 @@ export default function Sidebar({ // 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] = useState(false); + const { libraryDropdownOpen, setLibraryDropdownOpen, dropdownRect, libraryTriggerRef } = + useSidebarLibraryDropdown(); const [playlistsExpanded, setPlaylistsExpanded] = useState(false); const playlistsRaw = usePlaylistStore(s => s.playlists); const playlistsLoading = usePlaylistStore(s => s.playlistsLoading); @@ -128,16 +78,13 @@ export default function Sidebar({ const playlists = useMemo(() => { return [...playlistsRaw].sort((a, b) => a.name.localeCompare(b.name)); }, [playlistsRaw]); - const [dropdownRect, setDropdownRect] = useState({ top: 0, left: 0, width: 0 }); - const libraryTriggerRef = useRef(null); const [sidebarViewportEl, setSidebarViewportEl] = useState(null); - const [isSidebarScrolling, setIsSidebarScrolling] = useState(false); + const isSidebarScrolling = useSidebarScrollVisible(sidebarViewportEl); const showLibraryPicker = !isCollapsed && isLoggedIn && musicFolders.length > 1; const filterId = serverId ? (musicLibraryFilterByServer[serverId] ?? 'all') : 'all'; const selectedFolderName = filterId === 'all' ? null : musicFolders.find(f => f.id === filterId)?.name ?? null; - const libraryTriggerPlain = filterId === 'all'; const libraryItemsForReorder = useMemo( () => getLibraryItemsForReorder(sidebarItems, randomNavMode), @@ -161,380 +108,34 @@ export default function Sidebar({ [systemItemsForReorder], ); - const [navDnd, setNavDnd] = useState<{ - section: 'library' | 'system'; - fromIdx: number; - } | null>(null); - const [navDropTarget, setNavDropTarget] = useState(null); - const navDropTargetRef = useRef(null); - navDropTargetRef.current = navDropTarget; const sidebarItemsRef = useRef(sidebarItems); sidebarItemsRef.current = sidebarItems; const randomNavModeRef = useRef(randomNavMode); randomNavModeRef.current = randomNavMode; - /** DOM timers are numeric; avoid NodeJS `Timeout` typing from `setTimeout`. */ - const longPressTimersRef = useRef>(new Map()); - const suppressNavClickRef = useRef(false); - const lastPointerDuringNavDndRef = useRef({ x: 0, y: 0 }); - const [navDndTrashHint, setNavDndTrashHint] = useState<{ x: number; y: number } | null>(null); - const [newReleasesUnreadCount, setNewReleasesUnreadCount] = useState(0); - const newReleasesRefreshSeqRef = useRef(0); - const newReleasesPageEnteredAtRef = useRef(null); - const newReleasesResetTimerRef = useRef(null); - const [perfProbeOpen, setPerfProbeOpen] = useState(false); - const perfFlags = usePerfProbeFlags(); - const [perfCpu, setPerfCpu] = useState<{ app: number; webkit: number; supported: boolean } | null>(null); - const [perfDiagRates, setPerfDiagRates] = useState<{ progress: number; waveform: number; home: number } | null>(null); - useEffect(() => { - setPerfProbeTelemetryActive(perfProbeOpen); - return () => setPerfProbeTelemetryActive(false); - }, [perfProbeOpen]); - - 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); - let 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. - } - }, [ + const { + navDnd, + navDndTrashHint, + suppressNavClickRef, + handleNavRowPointerDown, + navDndRowClass, + } = useSidebarNavDnd({ + isCollapsed, + sidebarItemsRef, + randomNavModeRef, + setSidebarItems, + }); + const newReleasesUnreadCount = useSidebarNewReleasesUnread({ + serverId, filterId, isLoggedIn, - newReleasesSeenAllScopeStorageKey, - newReleasesSeenStorageKey, - readSeenNewReleaseIds, - readSeenNewReleaseIdsByKey, - serverId, - writeSeenNewReleaseIds, - writeSeenNewReleaseIdsByKey, - ]); + pathname: location.pathname, + }); + const { perfProbeOpen, setPerfProbeOpen, perfCpu, perfDiagRates } = useSidebarPerfProbe(); + const perfFlags = usePerfProbeFlags(); - useEffect(() => { - if (!navDnd) return; - const updateDropFromPoint = (clientX: number, clientY: number) => { - if (isPointerOutsideAsideSidebar(clientX, clientY)) { - navDropTargetRef.current = null; - setNavDropTarget(null); - return; - } - const rows = document.querySelectorAll('.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 }; - // Prevent synthetic click/navigation right after finishing a drag gesture. - 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]); - - 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], - ); - - 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]); const pickLibrary = (id: 'all' | string) => { setMusicLibraryFilter(id); @@ -547,178 +148,6 @@ export default function Sidebar({ fetchPlaylists(); }, [playlistsExpanded, isLoggedIn, fetchPlaylists]); - useEffect(() => () => { - longPressTimersRef.current.forEach(t => window.clearTimeout(t)); - longPressTimersRef.current.clear(); - }, []); - - 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]); - - useEffect(() => { - const onNewReleasesPage = location.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 = location.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; - } - }; - }, [location.pathname, refreshNewReleasesUnread]); - - 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('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 }; - 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 ( <> @@ -1094,396 +253,19 @@ export default function Sidebar({ , document.body, )} - {perfProbeOpen && - createPortal( -
setPerfProbeOpen(false)} role="dialog" aria-modal="true"> -
e.stopPropagation()} - style={{ maxWidth: 560 }} - > - -

Performance Probe

-

- Temporary runtime switches to estimate UI effect cost. -

- -
-
Live CPU (approx)
- {perfCpu == null ? ( -
Collecting samples…
- ) : perfCpu.supported ? ( - <> -
psysonic: {perfCpu.app.toFixed(1)}%
-
WebKitWebProcess: {perfCpu.webkit.toFixed(1)}%
- {perfDiagRates && ( - <> -
audio:progress rate: {perfDiagRates.progress.toFixed(1)}/s
-
waveform draws rate: {perfDiagRates.waveform.toFixed(1)}/s
-
Home commits rate: {perfDiagRates.home.toFixed(1)}/s
- - )} - - ) : ( -
Unavailable on this platform/build.
- )} -
-
- Phase 1 — Global / Shell / Network - - - - - - - - - -
Engine/network toggles
- - - -
-
- Phase 2 — Mainstage (Center Content) - -
- Shared mainstage layers (multiple pages) - -
- -
- Home (`/`) - - - - - - - - - - - -
- -
- Tracks (`/tracks`) - - - - - -
- -
- Albums (`/albums`) - -
-
-
- Phase 3 — Active diagnostics (quick access) - - - - - - - - -
-
- - -
-
-
, - document.body, - )} + setPerfProbeOpen(false)} + perfFlags={perfFlags} + perfCpu={perfCpu} + perfDiagRates={perfDiagRates} + hotCacheEnabled={hotCacheEnabled} + setHotCacheEnabled={setHotCacheEnabled} + normalizationEngine={normalizationEngine} + setNormalizationEngine={setNormalizationEngine} + loggingMode={loggingMode} + setLoggingMode={setLoggingMode} + /> ); } diff --git a/src/components/sidebar/SidebarActiveJobs.tsx b/src/components/sidebar/SidebarActiveJobs.tsx new file mode 100644 index 00000000..34bdc420 --- /dev/null +++ b/src/components/sidebar/SidebarActiveJobs.tsx @@ -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 && ( +
+ + {!isCollapsed && ( + {t('sidebar.downloadingTracks', { n: activeJobsCount })} + )} + +
+ )} + + {isSyncing && ( +
+ + {!isCollapsed && ( + {t('sidebar.syncingTracks', { done: syncJobDone + syncJobSkip + syncJobFail, total: syncJobTotal })} + )} +
+ )} + + ); +} diff --git a/src/components/sidebar/SidebarLibraryPicker.tsx b/src/components/sidebar/SidebarLibraryPicker.tsx new file mode 100644 index 00000000..f7b0c188 --- /dev/null +++ b/src/components/sidebar/SidebarLibraryPicker.tsx @@ -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; + 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 ( + <> + + {libraryDropdownOpen && + createPortal( +
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', + }} + > + + {musicFolders.map(f => ( + + ))} +
, + document.body + )} + + ); +} diff --git a/src/components/sidebar/SidebarNavBody.tsx b/src/components/sidebar/SidebarNavBody.tsx new file mode 100644 index 00000000..251a59f9 --- /dev/null +++ b/src/components/sidebar/SidebarNavBody.tsx @@ -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; + 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 ? ( + + ) : ( + {t('sidebar.library')} + ))} + {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 ? ( +
+
+ `nav-link sidebar-playlists-main-link ${isActive ? 'active' : ''}`} + > + + {t(item.labelKey)} + + +
+ {playlistsExpanded && ( +
+ {playlistsLoading ? ( +
+
+
+ ) : playlists.length === 0 ? ( +
{t('playlists.empty')}
+ ) : ( + playlists.map((pl: { id: string; name: string }) => ( + `nav-link sidebar-playlist-item ${isActive ? 'active' : ''}`} + > + {isSmartPlaylistName(pl.name) ? : } + {displayPlaylistName(pl.name)} + + )) + )} +
+ )} +
+ ) : isCollapsed ? ( + `nav-link ${isActive ? 'active' : ''}`} + data-tooltip={isCollapsed ? t(item.labelKey) : undefined} + data-tooltip-pos="bottom" + > + + {item.to === '/new-releases' && newReleasesUnreadCount > 0 && ( + + {newReleasesUnreadCount > 99 ? '99+' : newReleasesUnreadCount} + + )} + {!isCollapsed && {t(item.labelKey)}} + + ) : ( +
+ `nav-link ${isActive ? 'active' : ''}`} + data-tooltip={isCollapsed ? t(item.labelKey) : undefined} + data-tooltip-pos="bottom" + > + + {!isCollapsed && {t(item.labelKey)}} + {item.to === '/new-releases' && newReleasesUnreadCount > 0 && ( + + {newReleasesUnreadCount > 99 ? '99+' : newReleasesUnreadCount} + + )} + +
+ ); + })} + + {/* Spacer: everything from here onward sticks to the bottom of the sidebar. */} +
+ + {/* What's New banner — only visible while the current release hasn't been seen. */} + + + {/* Now Playing — fixed, always visible */} + `nav-link nav-link-nowplaying ${isActive ? 'active' : ''}`} + data-tooltip={isCollapsed ? t('sidebar.nowPlaying') : undefined} + data-tooltip-pos="bottom" + > + + + {isPlaying && hasNowPlayingTrack && } + + {!isCollapsed && {t('sidebar.nowPlaying')}} + + + {hasOfflineContent && ( + `nav-link nav-link-offline ${isActive ? 'active' : ''}`} + data-tooltip={isCollapsed ? t('sidebar.offlineLibrary') : undefined} + data-tooltip-pos="bottom" + > + + {!isCollapsed && {t('sidebar.offlineLibrary')}} + + )} + + {visibleSystemConfigs.length > 0 && !isCollapsed && {t('sidebar.system')}} + {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 ? ( + `nav-link ${isActive ? 'active' : ''}`} + data-tooltip={isCollapsed ? t(item.labelKey) : undefined} + data-tooltip-pos="bottom" + > + + {!isCollapsed && {t(item.labelKey)}} + + ) : ( +
+ `nav-link ${isActive ? 'active' : ''}`} + data-tooltip={isCollapsed ? t(item.labelKey) : undefined} + data-tooltip-pos="bottom" + > + + {!isCollapsed && {t(item.labelKey)}} + +
+ ); + })} + `nav-link ${isActive ? 'active' : ''}`} + data-tooltip={isCollapsed ? t('sidebar.settings') : undefined} + data-tooltip-pos="bottom" + > + + {!isCollapsed && {t('sidebar.settings')}} + + + + + ); +} diff --git a/src/components/sidebar/SidebarPerfProbeModal.tsx b/src/components/sidebar/SidebarPerfProbeModal.tsx new file mode 100644 index 00000000..80ae2634 --- /dev/null +++ b/src/components/sidebar/SidebarPerfProbeModal.tsx @@ -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( +
onClose()} role="dialog" aria-modal="true"> +
e.stopPropagation()} + style={{ maxWidth: 560 }} + > + +

Performance Probe

+

+ Temporary runtime switches to estimate UI effect cost. +

+ +
+
Live CPU (approx)
+ {perfCpu == null ? ( +
Collecting samples…
+ ) : perfCpu.supported ? ( + <> +
psysonic: {perfCpu.app.toFixed(1)}%
+
WebKitWebProcess: {perfCpu.webkit.toFixed(1)}%
+ {perfDiagRates && ( + <> +
audio:progress rate: {perfDiagRates.progress.toFixed(1)}/s
+
waveform draws rate: {perfDiagRates.waveform.toFixed(1)}/s
+
Home commits rate: {perfDiagRates.home.toFixed(1)}/s
+ + )} + + ) : ( +
Unavailable on this platform/build.
+ )} +
+
+ Phase 1 — Global / Shell / Network + + + + + + + + + +
Engine/network toggles
+ + + +
+ +
+ Phase 3 — Active diagnostics (quick access) + + + + + + + + +
+
+ + +
+
+
, + document.body, + ); +} diff --git a/src/components/sidebar/SidebarPerfProbePhase2.tsx b/src/components/sidebar/SidebarPerfProbePhase2.tsx new file mode 100644 index 00000000..a79d5988 --- /dev/null +++ b/src/components/sidebar/SidebarPerfProbePhase2.tsx @@ -0,0 +1,176 @@ +import { setPerfProbeFlag, type PerfProbeFlags } from '../../utils/perfFlags'; + +export default function SidebarPerfProbePhase2({ perfFlags }: { perfFlags: PerfProbeFlags }) { + return ( +
+ Phase 2 — Mainstage (Center Content) + +
+ Shared mainstage layers (multiple pages) + +
+ +
+ Home (`/`) + + + + + + + + + + + +
+ +
+ Tracks (`/tracks`) + + + + + +
+ +
+ Albums (`/albums`) + +
+
+ ); +} diff --git a/src/hooks/useSidebarLibraryDropdown.ts b/src/hooks/useSidebarLibraryDropdown.ts new file mode 100644 index 00000000..57a42e76 --- /dev/null +++ b/src/hooks/useSidebarLibraryDropdown.ts @@ -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; +} + +export function useSidebarLibraryDropdown(): Result { + const [libraryDropdownOpen, setLibraryDropdownOpen] = useState(false); + const [dropdownRect, setDropdownRect] = useState({ top: 0, left: 0, width: 0 }); + const libraryTriggerRef = useRef(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 }; +} diff --git a/src/hooks/useSidebarNavDnd.ts b/src/hooks/useSidebarNavDnd.ts new file mode 100644 index 00000000..94da9760 --- /dev/null +++ b/src/hooks/useSidebarNavDnd.ts @@ -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; + 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; + 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(null); + const [navDropTarget, setNavDropTarget] = useState(null); + const navDropTargetRef = useRef(null); + navDropTargetRef.current = navDropTarget; + /** DOM timers are numeric; avoid NodeJS `Timeout` typing from `setTimeout`. */ + const longPressTimersRef = useRef>(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('.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, + }; +} diff --git a/src/hooks/useSidebarNewReleasesUnread.ts b/src/hooks/useSidebarNewReleasesUnread.ts new file mode 100644 index 00000000..f7574dfe --- /dev/null +++ b/src/hooks/useSidebarNewReleasesUnread.ts @@ -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(null); + const newReleasesResetTimerRef = useRef(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; +} diff --git a/src/hooks/useSidebarPerfProbe.ts b/src/hooks/useSidebarPerfProbe.ts new file mode 100644 index 00000000..6765017a --- /dev/null +++ b/src/hooks/useSidebarPerfProbe.ts @@ -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(null); + const [perfDiagRates, setPerfDiagRates] = useState(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('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 }; + 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 }; +} diff --git a/src/hooks/useSidebarScrollVisible.ts b/src/hooks/useSidebarScrollVisible.ts new file mode 100644 index 00000000..2aab58ba --- /dev/null +++ b/src/hooks/useSidebarScrollVisible.ts @@ -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; +} diff --git a/src/utils/sidebarHelpers.ts b/src/utils/sidebarHelpers.ts new file mode 100644 index 00000000..ad258664 --- /dev/null +++ b/src/utils/sidebarHelpers.ts @@ -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(); + 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; +}