import React, { useState, useRef, useLayoutEffect, useEffect, useCallback, useMemo } from 'react'; import { createPortal } from 'react-dom'; 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 } 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 PsysonicLogo from './PsysonicLogo'; import PSmallLogo from './PSmallLogo'; import WhatsNewBanner from './WhatsNewBanner'; import { getPlaylists } from '../api/subsonic'; 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'; const SIDEBAR_NAV_LONG_PRESS_MS = 1000; const SIDEBAR_NAV_LONG_PRESS_MOVE_CANCEL_PX = 10; const SMART_PREFIX = 'psy-smart-'; 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; } export default function Sidebar({ isCollapsed = false, toggleCollapse, }: { isCollapsed?: boolean; toggleCollapse?: () => void; }) { const { t } = useTranslation(); const isPlaying = usePlayerStore(s => s.isPlaying); const currentTrack = usePlayerStore(s => s.currentTrack); const offlineJobs = useOfflineJobStore(s => s.jobs); const cancelAllDownloads = useOfflineJobStore(s => s.cancelAllDownloads); const activeJobs = offlineJobs.filter(j => j.status === 'queued' || j.status === 'downloading'); const syncJobStatus = useDeviceSyncJobStore(s => s.status); const syncJobDone = useDeviceSyncJobStore(s => s.done); const syncJobSkip = useDeviceSyncJobStore(s => s.skipped); const syncJobFail = useDeviceSyncJobStore(s => s.failed); const syncJobTotal = useDeviceSyncJobStore(s => s.total); const isSyncing = syncJobStatus === 'running'; const offlineAlbums = useOfflineStore(s => s.albums); const serverId = useAuthStore(s => s.activeServerId ?? ''); const isLoggedIn = useAuthStore(s => s.isLoggedIn); const musicFolders = useAuthStore(s => s.musicFolders); const musicLibraryFilterByServer = useAuthStore(s => s.musicLibraryFilterByServer); const setMusicLibraryFilter = useAuthStore(s => s.setMusicLibraryFilter); const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId); const sidebarItems = useSidebarStore(s => s.items); const setSidebarItems = useSidebarStore(s => s.setItems); const randomNavMode = useAuthStore(s => s.randomNavMode); const luckyMixBase = useLuckyMixAvailable(); // Sidebar surfaces Lucky Mix as its own entry only in "separate" nav mode — // in hub mode it lives inside the Build-a-Mix landing page instead. const luckyMixAvailable = luckyMixBase && randomNavMode === 'separate'; const [libraryDropdownOpen, setLibraryDropdownOpen] = useState(false); const [playlistsExpanded, setPlaylistsExpanded] = useState(false); const playlistsRaw = usePlaylistStore(s => s.playlists); const playlistsLoading = usePlaylistStore(s => s.playlistsLoading); const fetchPlaylists = usePlaylistStore(s => s.fetchPlaylists); // Sort playlists alphabetically by name const playlists = useMemo(() => { return [...playlistsRaw].sort((a, b) => a.name.localeCompare(b.name)); }, [playlistsRaw]); const [dropdownRect, setDropdownRect] = useState({ top: 0, left: 0, width: 0 }); const libraryTriggerRef = useRef(null); 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), [sidebarItems, randomNavMode], ); const systemItemsForReorder = useMemo( () => getSystemItemsForReorder(sidebarItems), [sidebarItems], ); const visibleLibraryConfigs = useMemo( () => libraryItemsForReorder.filter(c => { if (!c.visible) return false; if (c.id === 'luckyMix' && !luckyMixAvailable) return false; return true; }), [libraryItemsForReorder, luckyMixAvailable], ); const visibleSystemConfigs = useMemo( () => systemItemsForReorder.filter(c => c.visible), [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); 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 }; 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); setLibraryDropdownOpen(false); }; // Fetch playlists when expanded useEffect(() => { if (!playlistsExpanded || !isLoggedIn) return; fetchPlaylists(); }, [playlistsExpanded, isLoggedIn, fetchPlaylists]); useEffect(() => () => { longPressTimersRef.current.forEach(t => window.clearTimeout(t)); longPressTimersRef.current.clear(); }, []); return ( <> {navDndTrashHint != null && createPortal(
, document.body, )} ); }