import { useState, useRef, useEffect, useMemo } from 'react'; import { createPortal } from 'react-dom'; import { usePlayerStore } from '../store/playerStore'; import { useOfflineJobStore } from '../store/offlineJobStore'; import { clearOfflinePinTasks } from '../utils/offline/offlinePinQueue'; import { useDeviceSyncJobStore } from '../store/deviceSyncJobStore'; import { useAuthStore } from '../store/authStore'; import { useSidebarStore } from '../store/sidebarStore'; import { useLocation } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { PanelLeft, PanelLeftClose, Trash2 } from 'lucide-react'; import PsysonicLogo from './PsysonicLogo'; import PSmallLogo from './PSmallLogo'; import { usePlaylistStore } from '../store/playlistStore'; import OverlayScrollArea from './OverlayScrollArea'; import { getLibraryItemsForReorder, getSystemItemsForReorder, } from '../utils/componentHelpers/sidebarNavReorder'; import { useLuckyMixAvailable } from '../hooks/useLuckyMixAvailable'; import { usePerfProbeFlags } from '../utils/perf/perfFlags'; import { useSidebarNewReleasesUnread } from '../hooks/useSidebarNewReleasesUnread'; import { useSidebarNavDnd } from '../hooks/useSidebarNavDnd'; import { useSidebarLibraryDropdown } from '../hooks/useSidebarLibraryDropdown'; import { useSidebarScrollVisible } from '../hooks/useSidebarScrollVisible'; import { isOfflineSidebarNavAllowed } from '../utils/offline/offlineNavPolicy'; import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext'; import { offlineBrowseNavFlags } from '../utils/offline/offlineBrowseContext'; import { useSidebarPerfProbe } from '../hooks/useSidebarPerfProbe'; import SidebarPerfProbeModal from './sidebar/SidebarPerfProbeModal'; import SidebarNavBody from './sidebar/SidebarNavBody'; export default function Sidebar({ isCollapsed = false, toggleCollapse, }: { isCollapsed?: boolean; toggleCollapse?: () => void; }) { const { t } = useTranslation(); const location = useLocation(); const isPlaying = usePlayerStore(s => s.isPlaying); const currentTrack = usePlayerStore(s => s.currentTrack); const offlineJobs = useOfflineJobStore(s => s.jobs); const pinQueue = useOfflineJobStore(s => s.pinQueue); const cancelAllDownloadsStore = useOfflineJobStore(s => s.cancelAllDownloads); const activeJobs = offlineJobs.filter(j => j.status === 'queued' || j.status === 'downloading'); const activePin = pinQueue.find(p => p.status === 'downloading') ?? pinQueue.find(p => p.status === 'queued'); const queuedPinCount = pinQueue.filter(p => p.status === 'queued').length; const cancelAllDownloads = () => { clearOfflinePinTasks(); cancelAllDownloadsStore(); }; const syncJobStatus = useDeviceSyncJobStore(s => s.status); const syncJobDone = useDeviceSyncJobStore(s => s.done); const syncJobSkip = useDeviceSyncJobStore(s => s.skipped); const syncJobFail = useDeviceSyncJobStore(s => s.failed); const syncJobTotal = useDeviceSyncJobStore(s => s.total); const isSyncing = syncJobStatus === 'running'; const offlineCtx = useOfflineBrowseContext(); const offlineNav = offlineBrowseNavFlags(offlineCtx.capabilities); const serverId = useAuthStore(s => s.activeServerId ?? ''); const isLoggedIn = useAuthStore(s => s.isLoggedIn); const musicFolders = useAuthStore(s => s.musicFolders); const musicLibraryFilterByServer = useAuthStore(s => s.musicLibraryFilterByServer); const setMusicLibraryFilter = useAuthStore(s => s.setMusicLibraryFilter); const hotCacheEnabled = useAuthStore(s => s.hotCacheEnabled); const setHotCacheEnabled = useAuthStore(s => s.setHotCacheEnabled); const normalizationEngine = useAuthStore(s => s.normalizationEngine); const setNormalizationEngine = useAuthStore(s => s.setNormalizationEngine); const loggingMode = useAuthStore(s => s.loggingMode); const setLoggingMode = useAuthStore(s => s.setLoggingMode); const hasOfflineContent = offlineCtx.capabilities.manualPins; const isServerOffline = offlineCtx.active; const sidebarItems = useSidebarStore(s => s.items); const setSidebarItems = useSidebarStore(s => s.setItems); const randomNavMode = useAuthStore(s => s.randomNavMode); const nowPlayingAtTop = useAuthStore(s => s.nowPlayingAtTop); const luckyMixBase = useLuckyMixAvailable(); // Sidebar surfaces Lucky Mix as its own entry only in "separate" nav mode — // in hub mode it lives inside the Build-a-Mix landing page instead. const luckyMixAvailable = luckyMixBase && randomNavMode === 'separate'; const { libraryDropdownOpen, setLibraryDropdownOpen, dropdownRect, libraryTriggerRef } = useSidebarLibraryDropdown(); const [playlistsExpanded, setPlaylistsExpanded] = useState(false); const playlistsRaw = usePlaylistStore(s => s.playlists); const playlistsLoading = usePlaylistStore(s => s.playlistsLoading); const fetchPlaylists = usePlaylistStore(s => s.fetchPlaylists); // Sort playlists alphabetically by name const playlists = useMemo(() => { return [...playlistsRaw].sort((a, b) => a.name.localeCompare(b.name)); }, [playlistsRaw]); const [sidebarViewportEl, setSidebarViewportEl] = useState(null); const isSidebarScrolling = useSidebarScrollVisible(sidebarViewportEl); const showLibraryPicker = !isCollapsed && isLoggedIn && musicFolders.length > 1 && !isServerOffline; const filterId = serverId ? (musicLibraryFilterByServer[serverId] ?? 'all') : 'all'; const selectedFolderName = filterId === 'all' ? null : musicFolders.find(f => f.id === filterId)?.name ?? null; const libraryItemsForReorder = useMemo( () => getLibraryItemsForReorder(sidebarItems, randomNavMode), [sidebarItems, randomNavMode], ); const systemItemsForReorder = useMemo( () => getSystemItemsForReorder(sidebarItems), [sidebarItems], ); const visibleLibraryConfigs = useMemo( () => libraryItemsForReorder.filter(c => { if (!c.visible) return false; if (c.id === 'luckyMix' && !luckyMixAvailable) return false; if (isServerOffline && !isOfflineSidebarNavAllowed( c.id, offlineNav.favoritesOfflineBrowse, offlineNav.localLibraryBrowse, offlineNav.playerStatsBrowse, offlineNav.playlistsOfflineBrowse, )) { return false; } return true; }), [libraryItemsForReorder, luckyMixAvailable, isServerOffline, offlineNav], ); const visibleSystemConfigs = useMemo( () => systemItemsForReorder.filter(c => { if (!c.visible) return false; if (isServerOffline && !isOfflineSidebarNavAllowed( c.id, offlineNav.favoritesOfflineBrowse, offlineNav.localLibraryBrowse, offlineNav.playerStatsBrowse, offlineNav.playlistsOfflineBrowse, )) { return false; } return true; }), [systemItemsForReorder, isServerOffline, offlineNav], ); const sidebarItemsRef = useRef(sidebarItems); // React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data. // eslint-disable-next-line react-hooks/refs sidebarItemsRef.current = sidebarItems; const randomNavModeRef = useRef(randomNavMode); // React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data. // eslint-disable-next-line react-hooks/refs randomNavModeRef.current = randomNavMode; const { navDnd, navDndTrashHint, suppressNavClickRef, handleNavRowPointerDown, navDndRowClass, } = useSidebarNavDnd({ isCollapsed, sidebarItemsRef, randomNavModeRef, setSidebarItems, }); const newReleasesUnreadCount = useSidebarNewReleasesUnread({ serverId, filterId, isLoggedIn, pathname: location.pathname, }); const { perfProbeOpen, setPerfProbeOpen } = useSidebarPerfProbe(); const perfFlags = usePerfProbeFlags(); const pickLibrary = (id: 'all' | string) => { if (isServerOffline) return; setMusicLibraryFilter(id); setLibraryDropdownOpen(false); }; useEffect(() => { if (isServerOffline) setLibraryDropdownOpen(false); }, [isServerOffline, setLibraryDropdownOpen]); // Fetch playlists when expanded useEffect(() => { if (!playlistsExpanded || !isLoggedIn) return; fetchPlaylists(); }, [playlistsExpanded, isLoggedIn, fetchPlaylists]); return ( <> {navDndTrashHint != null && createPortal(
, document.body, )} setPerfProbeOpen(false)} perfFlags={perfFlags} hotCacheEnabled={hotCacheEnabled} setHotCacheEnabled={setHotCacheEnabled} normalizationEngine={normalizationEngine} setNormalizationEngine={setNormalizationEngine} loggingMode={loggingMode} setLoggingMode={setLoggingMode} /> ); }