mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
e9e61b9a05
- Drop the ~110 lines of dead full-screen overlay CSS that was never
referenced in JSX (.lucky-mix-overlay*, .lucky-mix-cube*, full-screen
@keyframes). Keep the .lucky-mix-pip* rules since the inline queue
variant re-uses them.
- Extract availability gate into hooks/useLuckyMixAvailable.ts (pure
predicate + hook). Replaces five duplicated inline checks in Sidebar,
MobileMoreOverlay, RandomLanding, Settings/SidebarCustomizer, and the
internal re-check in buildAndPlayLuckyMix.
- Add a cancel mechanic:
* luckyMixStore gets a cancelRequested flag + cancel() method
* LuckyMixCancelled sentinel is thrown from a bailIfCancelled() helper
sprinkled between await boundaries in the build loop
* catch-block swallows the sentinel silently (no toast, no error state)
* auto-cancel subscription on playerStore detects manual user track
changes (anything not in queuedIds) and flips cancelRequested so the
finished mix does not later overwrite the user's choice
* inline .queue-lucky-loading dice indicator is now a button — click
triggers explicit cancel, hover fades the dice to signal the action
- Restore the queue snapshot when the build fails before ever starting
playback, so the user does not land in an empty player after an error.
If playTrack already ran, leave current state alone (their track plus
whatever was enqueued is more useful than a stale pre-click queue).
- Rework locale labels to consistently carry the "Mix" suffix so the
entry reads well next to "Random Mix" / "Random Albums" in the
sidebar: DE Glücks-Mix, ES Mezcla Suerte, FR Mix Chance, NB Lykkemiks,
NL Geluksmix, ZH 好运混音. EN stays "Lucky Mix", RU stays
«Мне повезёт». Toast/settings/randomNavSplit copy aligned to the new
names. New luckyMix.cancelTooltip key added in all 8 locales.
AudioMuse gating stays — as Cuca confirmed, the feature's quality relies
on a correct similar-track selection, so the requirement is intentional.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
760 lines
30 KiB
TypeScript
760 lines
30 KiB
TypeScript
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, 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;
|
|
|
|
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<HTMLButtonElement>(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<SidebarNavDropTarget | null>(null);
|
|
const navDropTargetRef = useRef<SidebarNavDropTarget | null>(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<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(() => {
|
|
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 };
|
|
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 (
|
|
<>
|
|
<aside className={`sidebar animate-slide-in ${isCollapsed ? 'collapsed' : ''}`}>
|
|
<div className="sidebar-brand">
|
|
{isCollapsed
|
|
? <PSmallLogo style={{ height: '32px', width: 'auto' }} />
|
|
: <PsysonicLogo style={{ height: '28px', width: 'auto' }} />
|
|
}
|
|
</div>
|
|
|
|
<button
|
|
className="collapse-btn"
|
|
onClick={toggleCollapse}
|
|
data-tooltip={isCollapsed ? t('sidebar.expand') : t('sidebar.collapse')}
|
|
data-tooltip-pos="right"
|
|
>
|
|
{isCollapsed ? <PanelLeft size={14} /> : <PanelLeftClose size={14} />}
|
|
</button>
|
|
|
|
<nav
|
|
className="sidebar-nav"
|
|
aria-label="Main navigation"
|
|
onClickCapture={e => {
|
|
if (suppressNavClickRef.current) {
|
|
suppressNavClickRef.current = false;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}
|
|
}}
|
|
>
|
|
<OverlayScrollArea
|
|
className="sidebar-nav-scroll"
|
|
viewportClassName="sidebar-nav-viewport"
|
|
railInset="panel"
|
|
measureDeps={[
|
|
isCollapsed,
|
|
playlistsExpanded,
|
|
playlists.length,
|
|
isLoggedIn,
|
|
randomNavMode,
|
|
filterId,
|
|
hasOfflineContent,
|
|
activeJobs.length,
|
|
isSyncing,
|
|
syncJobTotal,
|
|
sidebarItems.length,
|
|
]}
|
|
>
|
|
{!isCollapsed && (showLibraryPicker ? (
|
|
<>
|
|
<button
|
|
ref={libraryTriggerRef}
|
|
type="button"
|
|
className={`nav-library-scope-trigger ${libraryTriggerPlain ? 'nav-library-scope-trigger--plain' : ''} ${libraryDropdownOpen ? 'nav-library-scope-trigger--open' : ''}`}
|
|
onClick={() => {
|
|
setLibraryDropdownOpen(o => !o);
|
|
}}
|
|
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
|
|
)}
|
|
</>
|
|
) : (
|
|
<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' ? (
|
|
<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' : ''}`}
|
|
data-tooltip={isCollapsed ? t(item.labelKey) : undefined}
|
|
data-tooltip-pos="bottom"
|
|
>
|
|
<item.icon size={isCollapsed ? 22 : 18} />
|
|
{!isCollapsed && <span>{t(item.labelKey)}</span>}
|
|
</NavLink>
|
|
{!isCollapsed && (
|
|
<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>
|
|
{!isCollapsed && 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' : ''}`}
|
|
data-tooltip={isCollapsed ? pl.name : undefined}
|
|
data-tooltip-pos="bottom"
|
|
>
|
|
<PlayCircle size={12} />
|
|
<span>{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} />
|
|
{!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>}
|
|
</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 && currentTrack && <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>
|
|
|
|
{activeJobs.length > 0 && (
|
|
<div
|
|
className={`sidebar-offline-queue ${isCollapsed ? 'sidebar-offline-queue--collapsed' : ''}`}
|
|
data-tooltip={isCollapsed ? t('sidebar.downloadingTracks', { n: activeJobs.length }) : undefined}
|
|
data-tooltip-pos="right"
|
|
>
|
|
<HardDriveDownload size={isCollapsed ? 18 : 14} className="spin-slow" />
|
|
{!isCollapsed && (
|
|
<span>{t('sidebar.downloadingTracks', { n: activeJobs.length })}</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>
|
|
)}
|
|
</OverlayScrollArea>
|
|
</nav>
|
|
</aside>
|
|
{navDndTrashHint != null &&
|
|
createPortal(
|
|
<div
|
|
className="sidebar-nav-dnd-trash-hint"
|
|
style={{
|
|
position: 'fixed',
|
|
left: navDndTrashHint.x + 14,
|
|
top: navDndTrashHint.y + 14,
|
|
}}
|
|
aria-hidden
|
|
>
|
|
<Trash2 size={22} strokeWidth={2.25} />
|
|
</div>,
|
|
document.body,
|
|
)}
|
|
</>
|
|
);
|
|
}
|