feat(subsonic): per-server music folder filter and sidebar picker (#125)

Adds library scoping per server: users can select one Navidrome music
folder or 'All' from a new sidebar dropdown. Choice is persisted per
server ID in localStorage. A musicLibraryFilterVersion counter triggers
refetches across browsing pages when the scope changes.

- getMusicFolders() + libraryFilterParams() in subsonic.ts
- New fields in authStore: musicFolders, musicLibraryFilterByServer,
  musicLibraryFilterVersion, setMusicFolders, setMusicLibraryFilter
- Sidebar dropdown via createPortal; collapses to icon in narrow mode
- API scoping applied to: getAlbumList2, getRandomSongs, getArtists,
  getStarred2, search3
- Full i18n coverage (en, de, fr, nl, zh, nb, ru)

Review fixes applied (co-authored):
- Sidebar: title={} → data-tooltip + data-tooltip-pos='right'
- authStore: setMusicFolders merged into single set() call
- Locales: removed dead libraryScopeHint key from all 7 files
- Genres.tsx: removed musicLibraryFilterVersion dep (getGenres unscoped)

Co-authored-by: cucadmuh <cucadmuh@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-07 13:54:57 +02:00
30 changed files with 489 additions and 35 deletions
+20
View File
@@ -53,6 +53,7 @@ import AppUpdater from './components/AppUpdater';
import { version } from '../package.json'; import { version } from '../package.json';
import { useConnectionStatus } from './hooks/useConnectionStatus'; import { useConnectionStatus } from './hooks/useConnectionStatus';
import { useAuthStore } from './store/authStore'; import { useAuthStore } from './store/authStore';
import { getMusicFolders } from './api/subsonic';
import { useOfflineStore } from './store/offlineStore'; import { useOfflineStore } from './store/offlineStore';
import { initHotCachePrefetch } from './hotCachePrefetch'; import { initHotCachePrefetch } from './hotCachePrefetch';
import { usePlayerStore, initAudioListeners } from './store/playerStore'; import { usePlayerStore, initAudioListeners } from './store/playerStore';
@@ -82,9 +83,28 @@ function AppShell() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const serverId = useAuthStore(s => s.activeServerId ?? ''); const serverId = useAuthStore(s => s.activeServerId ?? '');
const isLoggedIn = useAuthStore(s => s.isLoggedIn);
const activeServerId = useAuthStore(s => s.activeServerId);
const setMusicFolders = useAuthStore(s => s.setMusicFolders);
const offlineAlbums = useOfflineStore(s => s.albums); const offlineAlbums = useOfflineStore(s => s.albums);
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId); const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
useEffect(() => {
if (!isLoggedIn || !activeServerId) return;
let cancelled = false;
(async () => {
try {
const folders = await getMusicFolders();
if (!cancelled) setMusicFolders(folders);
} catch {
if (!cancelled) setMusicFolders([]);
}
})();
return () => {
cancelled = true;
};
}, [isLoggedIn, activeServerId, setMusicFolders]);
// Auto-navigate to offline library when no connection but cached content exists // Auto-navigate to offline library when no connection but cached content exists
const prevConnStatus = useRef(connStatus); const prevConnStatus = useRef(connStatus);
useEffect(() => { useEffect(() => {
+52 -6
View File
@@ -40,6 +40,15 @@ async function api<T>(endpoint: string, extra: Record<string, unknown> = {}, tim
return data as T; return data as T;
} }
/** Optional `musicFolderId` when the user narrowed browsing to one Subsonic library (see `getMusicFolders`). */
export function libraryFilterParams(): Record<string, string | number> {
const { activeServerId, musicLibraryFilterByServer } = useAuthStore.getState();
if (!activeServerId) return {};
const f = musicLibraryFilterByServer[activeServerId];
if (f === undefined || f === 'all') return {};
return { musicFolderId: f };
}
// ─── Types ──────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────
export interface SubsonicAlbum { export interface SubsonicAlbum {
id: string; id: string;
@@ -139,6 +148,11 @@ export interface SubsonicGenre {
albumCount: number; albumCount: number;
} }
export interface SubsonicMusicFolder {
id: string;
name: string;
}
export interface SubsonicArtistInfo { export interface SubsonicArtistInfo {
biography?: string; biography?: string;
musicBrainzId?: string; musicBrainzId?: string;
@@ -150,6 +164,19 @@ export interface SubsonicArtistInfo {
} }
// ─── API Methods ────────────────────────────────────────────── // ─── API Methods ──────────────────────────────────────────────
export async function getMusicFolders(): Promise<SubsonicMusicFolder[]> {
const data = await api<{ musicFolders: { musicFolder: SubsonicMusicFolder | SubsonicMusicFolder[] } }>(
'getMusicFolders.view',
);
const raw = data.musicFolders?.musicFolder;
if (!raw) return [];
const arr = Array.isArray(raw) ? raw : [raw];
return arr.map(f => ({
id: String((f as { id: string | number }).id),
name: (f as { name?: string }).name ?? 'Library',
}));
}
export async function ping(): Promise<boolean> { export async function ping(): Promise<boolean> {
try { try {
await api('ping.view'); await api('ping.view');
@@ -178,7 +205,11 @@ export async function pingWithCredentials(serverUrl: string, username: string, p
} }
export async function getRandomAlbums(size = 6): Promise<SubsonicAlbum[]> { export async function getRandomAlbums(size = 6): Promise<SubsonicAlbum[]> {
const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', { type: 'random', size }); const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', {
type: 'random',
size,
...libraryFilterParams(),
});
return data.albumList2?.album ?? []; return data.albumList2?.album ?? [];
} }
@@ -188,12 +219,19 @@ export async function getAlbumList(
offset = 0, offset = 0,
extra: Record<string, unknown> = {} extra: Record<string, unknown> = {}
): Promise<SubsonicAlbum[]> { ): Promise<SubsonicAlbum[]> {
const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', { type, size, offset, _t: Date.now(), ...extra }); const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', {
type,
size,
offset,
_t: Date.now(),
...libraryFilterParams(),
...extra,
});
return data.albumList2?.album ?? []; return data.albumList2?.album ?? [];
} }
export async function getRandomSongs(size = 50, genre?: string, timeout = 15000): Promise<SubsonicSong[]> { export async function getRandomSongs(size = 50, genre?: string, timeout = 15000): Promise<SubsonicSong[]> {
const params: Record<string, string | number> = { size, _t: Date.now() }; const params: Record<string, string | number> = { size, _t: Date.now(), ...libraryFilterParams() };
if (genre) params.genre = genre; if (genre) params.genre = genre;
const data = await api<{ randomSongs: { song: SubsonicSong[] } }>('getRandomSongs.view', params, timeout); const data = await api<{ randomSongs: { song: SubsonicSong[] } }>('getRandomSongs.view', params, timeout);
return data.randomSongs?.song ?? []; return data.randomSongs?.song ?? [];
@@ -215,7 +253,9 @@ export async function getAlbum(id: string): Promise<{ album: SubsonicAlbum; song
} }
export async function getArtists(): Promise<SubsonicArtist[]> { export async function getArtists(): Promise<SubsonicArtist[]> {
const data = await api<{ artists: { index: Array<{ artist: SubsonicArtist[] }> } }>('getArtists.view'); const data = await api<{ artists: { index: Array<{ artist: SubsonicArtist[] }> } }>('getArtists.view', {
...libraryFilterParams(),
});
const indices = data.artists?.index ?? []; const indices = data.artists?.index ?? [];
return indices.flatMap(i => i.artist ?? []); return indices.flatMap(i => i.artist ?? []);
} }
@@ -258,7 +298,12 @@ export async function getGenres(): Promise<SubsonicGenre[]> {
export async function getAlbumsByGenre(genre: string, size = 50, offset = 0): Promise<SubsonicAlbum[]> { export async function getAlbumsByGenre(genre: string, size = 50, offset = 0): Promise<SubsonicAlbum[]> {
const data = await api<{ albumList2: { album: SubsonicAlbum | SubsonicAlbum[] } }>('getAlbumList2.view', { const data = await api<{ albumList2: { album: SubsonicAlbum | SubsonicAlbum[] } }>('getAlbumList2.view', {
type: 'byGenre', genre, size, offset, _t: Date.now(), type: 'byGenre',
genre,
size,
offset,
_t: Date.now(),
...libraryFilterParams(),
}); });
const raw = data.albumList2?.album; const raw = data.albumList2?.album;
if (!raw) return []; if (!raw) return [];
@@ -284,7 +329,7 @@ export async function getStarred(): Promise<StarredResults> {
album?: SubsonicAlbum[]; album?: SubsonicAlbum[];
song?: SubsonicSong[]; song?: SubsonicSong[];
} }
}>('getStarred2.view'); }>('getStarred2.view', { ...libraryFilterParams() });
const r = data.starred2 ?? {}; const r = data.starred2 ?? {};
return { artists: r.artist ?? [], albums: r.album ?? [], songs: r.song ?? [] }; return { artists: r.artist ?? [], albums: r.album ?? [], songs: r.song ?? [] };
} }
@@ -318,6 +363,7 @@ export async function search(query: string, options?: { albumCount?: number; art
artistCount: options?.artistCount ?? 5, artistCount: options?.artistCount ?? 5,
albumCount: options?.albumCount ?? 5, albumCount: options?.albumCount ?? 5,
songCount: options?.songCount ?? 10, songCount: options?.songCount ?? 10,
...libraryFilterParams(),
}); });
const r = data.searchResult3 ?? {}; const r = data.searchResult3 ?? {};
return { artists: r.artist ?? [], albums: r.album ?? [], songs: r.song ?? [] }; return { artists: r.artist ?? [], albums: r.album ?? [], songs: r.song ?? [] };
+3 -1
View File
@@ -7,6 +7,7 @@ import { usePlayerStore, songToTrack } from '../store/playerStore';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { playAlbum } from '../utils/playAlbum'; import { playAlbum } from '../utils/playAlbum';
import { useIsMobile } from '../hooks/useIsMobile'; import { useIsMobile } from '../hooks/useIsMobile';
import { useAuthStore } from '../store/authStore';
const INTERVAL_MS = 10000; const INTERVAL_MS = 10000;
@@ -52,6 +53,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]); const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [activeIdx, setActiveIdx] = useState(0); const [activeIdx, setActiveIdx] = useState(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null); const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -59,7 +61,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
useEffect(() => { useEffect(() => {
if (albumsProp?.length) { setAlbums(albumsProp); return; } if (albumsProp?.length) { setAlbums(albumsProp); return; }
getRandomAlbums(8).then(a => { if (a.length) setAlbums(a); }).catch(() => {}); getRandomAlbums(8).then(a => { if (a.length) setAlbums(a); }).catch(() => {});
}, [albumsProp]); }, [albumsProp, musicLibraryFilterVersion]);
// Start / restart auto-advance timer // Start / restart auto-advance timer
const startTimer = useCallback((len: number) => { const startTimer = useCallback((len: number) => {
+3 -1
View File
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
import { Search, Disc3, Users, Music, SlidersHorizontal } from 'lucide-react'; import { Search, Disc3, Users, Music, SlidersHorizontal } from 'lucide-react';
import { search, SearchResults, buildCoverArtUrl } from '../api/subsonic'; import { search, SearchResults, buildCoverArtUrl } from '../api/subsonic';
import { usePlayerStore, songToTrack } from '../store/playerStore'; import { usePlayerStore, songToTrack } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
function debounce(fn: (q: string) => void, ms: number): (q: string) => void { function debounce(fn: (q: string) => void, ms: number): (q: string) => void {
@@ -24,6 +25,7 @@ export default function LiveSearch() {
const playTrack = usePlayerStore(state => state.playTrack); const playTrack = usePlayerStore(state => state.playTrack);
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const doSearch = useCallback( const doSearch = useCallback(
debounce(async (q: string) => { debounce(async (q: string) => {
@@ -37,7 +39,7 @@ export default function LiveSearch() {
setLoading(false); setLoading(false);
} }
}, 300), }, 300),
[] [musicLibraryFilterVersion]
); );
useEffect(() => { doSearch(query); setActiveIndex(-1); }, [query, doSearch]); useEffect(() => { doSearch(query); setActiveIndex(-1); }, [query, doSearch]);
+3 -1
View File
@@ -4,6 +4,7 @@ import { useNavigate } from 'react-router-dom';
import { X, Search, Disc3, Users, Music, Music2, Clock, ChevronRight } from 'lucide-react'; import { X, Search, Disc3, Users, Music, Music2, Clock, ChevronRight } from 'lucide-react';
import { search, SearchResults, buildCoverArtUrl } from '../api/subsonic'; import { search, SearchResults, buildCoverArtUrl } from '../api/subsonic';
import { usePlayerStore, songToTrack } from '../store/playerStore'; import { usePlayerStore, songToTrack } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
const STORAGE_KEY = 'psysonic_recent_searches'; const STORAGE_KEY = 'psysonic_recent_searches';
@@ -34,6 +35,7 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [recentSearches, setRecentSearches] = useState<string[]>(loadRecent); const [recentSearches, setRecentSearches] = useState<string[]>(loadRecent);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
useEffect(() => { inputRef.current?.focus(); }, []); useEffect(() => { inputRef.current?.focus(); }, []);
@@ -50,7 +52,7 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
try { setResults(await search(q)); } try { setResults(await search(q)); }
finally { setLoading(false); } finally { setLoading(false); }
}, 300), }, 300),
[] [musicLibraryFilterVersion]
); );
useEffect(() => { doSearch(query); }, [query, doSearch]); useEffect(() => { doSearch(query); }, [query, doSearch]);
+142 -5
View File
@@ -1,4 +1,5 @@
import React from 'react'; import React, { useState, useRef, useLayoutEffect, useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { usePlayerStore } from '../store/playerStore'; import { usePlayerStore } from '../store/playerStore';
import { useOfflineStore } from '../store/offlineStore'; import { useOfflineStore } from '../store/offlineStore';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
@@ -7,7 +8,8 @@ import { NavLink } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
Disc3, Users, Music4, Radio, Settings, Heart, BarChart3, Shuffle, Disc3, Users, Music4, Radio, Settings, Heart, BarChart3, Shuffle,
PanelLeftClose, PanelLeft, HelpCircle, Dices, AudioLines, HardDriveDownload, Tags, ListMusic, Cast PanelLeftClose, PanelLeft, HelpCircle, Dices, AudioLines, HardDriveDownload, Tags, ListMusic, Cast,
ChevronDown, Check, Music2,
} from 'lucide-react'; } from 'lucide-react';
import PsysonicLogo from './PsysonicLogo'; import PsysonicLogo from './PsysonicLogo';
import PSmallLogo from './PSmallLogo'; import PSmallLogo from './PSmallLogo';
@@ -44,8 +46,70 @@ export default function Sidebar({
const activeJobs = offlineJobs.filter(j => j.status === 'queued' || j.status === 'downloading'); const activeJobs = offlineJobs.filter(j => j.status === 'queued' || j.status === 'downloading');
const offlineAlbums = useOfflineStore(s => s.albums); const offlineAlbums = useOfflineStore(s => s.albums);
const serverId = useAuthStore(s => s.activeServerId ?? ''); 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 hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
const sidebarItems = useSidebarStore(s => s.items); const sidebarItems = useSidebarStore(s => s.items);
const [libraryDropdownOpen, setLibraryDropdownOpen] = useState(false);
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 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);
};
// Resolve ordered, visible items per section from store config // Resolve ordered, visible items per section from store config
const visibleLibrary = sidebarItems const visibleLibrary = sidebarItems
.filter(cfg => cfg.visible && ALL_NAV_ITEMS[cfg.id]?.section === 'library') .filter(cfg => cfg.visible && ALL_NAV_ITEMS[cfg.id]?.section === 'library')
@@ -73,8 +137,81 @@ export default function Sidebar({
{isCollapsed ? <PanelLeft size={14} /> : <PanelLeftClose size={14} />} {isCollapsed ? <PanelLeft size={14} /> : <PanelLeftClose size={14} />}
</button> </button>
<nav className="sidebar-nav" aria-label="Hauptnavigation"> <nav className="sidebar-nav" aria-label="Main navigation">
{!isCollapsed && <span className="nav-section-label">{t('sidebar.library')}</span>} {!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>
))}
{visibleLibrary.map(item => ( {visibleLibrary.map(item => (
<NavLink <NavLink
key={item.to} key={item.to}
@@ -117,7 +254,7 @@ export default function Sidebar({
)} )}
{visibleSystem.length > 0 && !isCollapsed && <span className="nav-section-label">{t('sidebar.system')}</span>} {visibleSystem.length > 0 && !isCollapsed && <span className="nav-section-label">{t('sidebar.system')}</span>}
{visibleSystem.map(item => ( {visibleSystem.map(item => (
<NavLink <NavLink
key={item.to} key={item.to}
to={item.to} to={item.to}
+7
View File
@@ -35,13 +35,20 @@ export default function TooltipPortal() {
const target = (e.target as HTMLElement).closest('[data-tooltip]'); const target = (e.target as HTMLElement).closest('[data-tooltip]');
if (!target) setTooltip(null); if (!target) setTooltip(null);
}; };
/** Clicking a tooltip anchor (e.g. opening a dropdown) keeps the cursor inside the element, so mouseout never runs — hide immediately. */
const onDown = (e: MouseEvent) => {
const t = (e.target as HTMLElement).closest('[data-tooltip]');
if (t) setTooltip(null);
};
document.addEventListener('mouseover', onOver); document.addEventListener('mouseover', onOver);
document.addEventListener('mouseout', onOut); document.addEventListener('mouseout', onOut);
document.addEventListener('mousemove', onMove, { passive: true }); document.addEventListener('mousemove', onMove, { passive: true });
document.addEventListener('mousedown', onDown, true);
return () => { return () => {
document.removeEventListener('mouseover', onOver); document.removeEventListener('mouseover', onOver);
document.removeEventListener('mouseout', onOut); document.removeEventListener('mouseout', onOut);
document.removeEventListener('mousemove', onMove); document.removeEventListener('mousemove', onMove);
document.removeEventListener('mousedown', onDown, true);
}; };
}, []); }, []);
+2
View File
@@ -20,6 +20,8 @@ export const deTranslation = {
genres: 'Genres', genres: 'Genres',
playlists: 'Playlists', playlists: 'Playlists',
radio: 'Internetradio', radio: 'Internetradio',
libraryScope: 'Bibliotheksumfang',
allLibraries: 'Alle Bibliotheken',
}, },
home: { home: {
hero: 'Featured', hero: 'Featured',
+2
View File
@@ -21,6 +21,8 @@ export const enTranslation = {
genres: 'Genres', genres: 'Genres',
playlists: 'Playlists', playlists: 'Playlists',
radio: 'Internet Radio', radio: 'Internet Radio',
libraryScope: 'Library scope',
allLibraries: 'All libraries',
}, },
home: { home: {
hero: 'Featured', hero: 'Featured',
+2
View File
@@ -20,6 +20,8 @@ export const frTranslation = {
genres: 'Genres', genres: 'Genres',
playlists: 'Playlists', playlists: 'Playlists',
radio: 'Radio Internet', radio: 'Radio Internet',
libraryScope: 'Portée de la bibliothèque',
allLibraries: 'Toutes les bibliothèques',
}, },
home: { home: {
hero: 'En vedette', hero: 'En vedette',
+2
View File
@@ -20,6 +20,8 @@ export const nbTranslation = {
genres: 'Sjangere', genres: 'Sjangere',
playlists: 'Spillelister', playlists: 'Spillelister',
radio: 'Internettradio', radio: 'Internettradio',
libraryScope: 'Biblioteksomfang',
allLibraries: 'Alle biblioteker',
}, },
home: { home: {
hero: 'Utvalgt', hero: 'Utvalgt',
+2
View File
@@ -20,6 +20,8 @@ export const nlTranslation = {
genres: 'Genres', genres: 'Genres',
playlists: 'Playlists', playlists: 'Playlists',
radio: 'Internetradio', radio: 'Internetradio',
libraryScope: 'Bibliotheekbereik',
allLibraries: 'Alle bibliotheken',
}, },
home: { home: {
hero: 'Uitgelicht', hero: 'Uitgelicht',
+2
View File
@@ -21,6 +21,8 @@ export const ruTranslation = {
genres: 'Жанры', genres: 'Жанры',
playlists: 'Плейлисты', playlists: 'Плейлисты',
radio: 'Онлайн-радио', radio: 'Онлайн-радио',
libraryScope: 'Область медиатеки',
allLibraries: 'Все библиотеки',
}, },
home: { home: {
hero: 'Подборка', hero: 'Подборка',
+2
View File
@@ -20,6 +20,8 @@ export const zhTranslation = {
genres: '流派', genres: '流派',
playlists: '播放列表', playlists: '播放列表',
radio: '网络电台', radio: '网络电台',
libraryScope: '资料库范围',
allLibraries: '所有资料库',
}, },
home: { home: {
hero: '精选', hero: '精选',
+5 -3
View File
@@ -11,6 +11,7 @@ import AlbumRow from '../components/AlbumRow';
import ArtistRow from '../components/ArtistRow'; import ArtistRow from '../components/ArtistRow';
import CustomSelect from '../components/CustomSelect'; import CustomSelect from '../components/CustomSelect';
import { useDragDrop } from '../contexts/DragDropContext'; import { useDragDrop } from '../contexts/DragDropContext';
import { useAuthStore } from '../store/authStore';
type ResultType = 'all' | 'artists' | 'albums' | 'songs'; type ResultType = 'all' | 'artists' | 'albums' | 'songs';
@@ -31,6 +32,7 @@ interface Results {
export default function AdvancedSearch() { export default function AdvancedSearch() {
const { t } = useTranslation(); const { t } = useTranslation();
const [params] = useSearchParams(); const [params] = useSearchParams();
const qFromUrl = params.get('q') ?? '';
const navigate = useNavigate(); const navigate = useNavigate();
const psyDrag = useDragDrop(); const psyDrag = useDragDrop();
const playTrack = usePlayerStore(s => s.playTrack); const playTrack = usePlayerStore(s => s.playTrack);
@@ -46,6 +48,7 @@ export default function AdvancedSearch() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [hasSearched, setHasSearched] = useState(false); const [hasSearched, setHasSearched] = useState(false);
const [genreNote, setGenreNote] = useState(false); const [genreNote, setGenreNote] = useState(false);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const runSearch = async (opts: SearchOpts) => { const runSearch = async (opts: SearchOpts) => {
setLoading(true); setLoading(true);
@@ -109,9 +112,8 @@ export default function AdvancedSearch() {
getGenres().then(data => getGenres().then(data =>
setGenres(data.sort((a, b) => a.value.localeCompare(b.value))) setGenres(data.sort((a, b) => a.value.localeCompare(b.value)))
).catch(() => {}); ).catch(() => {});
const q = params.get('q') ?? ''; if (qFromUrl) runSearch({ query: qFromUrl, genre: '', yearFrom: '', yearTo: '', resultType: 'all' });
if (q) runSearch({ query: q, genre: '', yearFrom: '', yearTo: '', resultType: 'all' }); }, [musicLibraryFilterVersion, qFromUrl]);
}, []);
const handleSubmit = (e?: React.FormEvent) => { const handleSubmit = (e?: React.FormEvent) => {
e?.preventDefault(); e?.preventDefault();
+4 -2
View File
@@ -3,6 +3,7 @@ import AlbumCard from '../components/AlbumCard';
import GenreFilterBar from '../components/GenreFilterBar'; import GenreFilterBar from '../components/GenreFilterBar';
import { getAlbumList, getAlbumsByGenre, SubsonicAlbum } from '../api/subsonic'; import { getAlbumList, getAlbumsByGenre, SubsonicAlbum } from '../api/subsonic';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/authStore';
import { X } from 'lucide-react'; import { X } from 'lucide-react';
type SortType = 'alphabeticalByName' | 'alphabeticalByArtist'; type SortType = 'alphabeticalByName' | 'alphabeticalByArtist';
@@ -18,6 +19,7 @@ async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> {
export default function Albums() { export default function Albums() {
const { t } = useTranslation(); const { t } = useTranslation();
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]); const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [sort, setSort] = useState<SortType>('alphabeticalByName'); const [sort, setSort] = useState<SortType>('alphabeticalByName');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -50,7 +52,7 @@ export default function Albums() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, [musicLibraryFilterVersion]);
const loadFiltered = useCallback(async (genres: string[], sortType: SortType) => { const loadFiltered = useCallback(async (genres: string[], sortType: SortType) => {
setLoading(true); setLoading(true);
@@ -66,7 +68,7 @@ export default function Albums() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, [musicLibraryFilterVersion]);
useEffect(() => { useEffect(() => {
setPage(0); setPage(0);
+3 -2
View File
@@ -70,6 +70,7 @@ export default function ArtistDetail() {
const isPlaying = usePlayerStore(state => state.isPlaying); const isPlaying = usePlayerStore(state => state.isPlaying);
const { downloadArtist, bulkProgress } = useOfflineStore(); const { downloadArtist, bulkProgress } = useOfflineStore();
const activeServerId = useAuthStore(s => s.activeServerId) ?? ''; const activeServerId = useAuthStore(s => s.activeServerId) ?? '';
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
useEffect(() => { useEffect(() => {
if (!id) return; if (!id) return;
@@ -126,7 +127,7 @@ export default function ArtistDetail() {
setFeaturedAlbums([...albumMap.values()]); setFeaturedAlbums([...albumMap.values()]);
setFeaturedLoading(false); setFeaturedLoading(false);
}); });
}, [artist?.id]); }, [artist?.id, musicLibraryFilterVersion]);
useEffect(() => { useEffect(() => {
if (!artist || !lastfmIsConfigured()) return; if (!artist || !lastfmIsConfigured()) return;
@@ -152,7 +153,7 @@ export default function ArtistDetail() {
setSimilarArtists(found); setSimilarArtists(found);
setSimilarLoading(false); setSimilarLoading(false);
}).catch(() => setSimilarLoading(false)); }).catch(() => setSimilarLoading(false));
}, [artist?.id]); }, [artist?.id, musicLibraryFilterVersion]);
const openLink = (url: string, key: string) => { const openLink = (url: string, key: string) => {
open(url); open(url);
+2 -1
View File
@@ -86,10 +86,11 @@ export default function Artists() {
const openContextMenu = usePlayerStore(state => state.openContextMenu); const openContextMenu = usePlayerStore(state => state.openContextMenu);
const showArtistImages = useAuthStore(s => s.showArtistImages); const showArtistImages = useAuthStore(s => s.showArtistImages);
const setShowArtistImages = useAuthStore(s => s.setShowArtistImages); const setShowArtistImages = useAuthStore(s => s.setShowArtistImages);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
useEffect(() => { useEffect(() => {
getArtists().then(data => { setArtists(data); setLoading(false); }).catch(() => setLoading(false)); getArtists().then(data => { setArtists(data); setLoading(false); }).catch(() => setLoading(false));
}, []); }, [musicLibraryFilterVersion]);
const loadMore = useCallback(() => { const loadMore = useCallback(() => {
setVisibleCount(prev => prev + 50); setVisibleCount(prev => prev + 50);
+3 -1
View File
@@ -14,6 +14,7 @@ import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { unstar } from '../api/subsonic'; import { unstar } from '../api/subsonic';
import { useDragDrop } from '../contexts/DragDropContext'; import { useDragDrop } from '../contexts/DragDropContext';
import { useAuthStore } from '../store/authStore';
const FAV_COLUMNS: readonly ColDef[] = [ const FAV_COLUMNS: readonly ColDef[] = [
{ key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: true }, { key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: true },
@@ -63,6 +64,7 @@ export default function Favorites() {
const openContextMenu = usePlayerStore(s => s.openContextMenu); const openContextMenu = usePlayerStore(s => s.openContextMenu);
const navigate = useNavigate(); const navigate = useNavigate();
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
useEffect(() => { useEffect(() => {
const loadAll = async () => { const loadAll = async () => {
@@ -87,7 +89,7 @@ export default function Favorites() {
setLoading(false); setLoading(false);
}; };
loadAll(); loadAll();
}, []); }, [musicLibraryFilterVersion]);
if (loading) { if (loading) {
return ( return (
+2
View File
@@ -3,6 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ArrowLeft, Disc3 } from 'lucide-react'; import { ArrowLeft, Disc3 } from 'lucide-react';
import { getAlbumsByGenre, SubsonicAlbum } from '../api/subsonic'; import { getAlbumsByGenre, SubsonicAlbum } from '../api/subsonic';
import { useAuthStore } from '../store/authStore';
import AlbumCard from '../components/AlbumCard'; import AlbumCard from '../components/AlbumCard';
const PAGE_SIZE = 50; const PAGE_SIZE = 50;
@@ -17,6 +18,7 @@ export default function GenreDetail() {
const [loadingMore, setLoadingMore] = useState(false); const [loadingMore, setLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(true); const [hasMore, setHasMore] = useState(true);
const [offset, setOffset] = useState(0); const [offset, setOffset] = useState(0);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
useEffect(() => { useEffect(() => {
setAlbums([]); setAlbums([]);
+2 -1
View File
@@ -7,6 +7,7 @@ import {
Tags, type LucideIcon, Tags, type LucideIcon,
} from 'lucide-react'; } from 'lucide-react';
import { getGenres, SubsonicGenre } from '../api/subsonic'; import { getGenres, SubsonicGenre } from '../api/subsonic';
import { useAuthStore } from '../store/authStore';
function getGenreIcon(name: string): LucideIcon { function getGenreIcon(name: string): LucideIcon {
const n = name.toLowerCase(); const n = name.toLowerCase();
@@ -59,7 +60,7 @@ export default function Genres() {
setGenres(sorted); setGenres(sorted);
}) })
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); }, []); // getGenres is not folder-scoped — no dep on musicLibraryFilterVersion
// Restore scroll position after genres are rendered // Restore scroll position after genres are rendered
useEffect(() => { useEffect(() => {
+3 -1
View File
@@ -6,9 +6,11 @@ import { useTranslation } from 'react-i18next';
import { NavLink, useNavigate } from 'react-router-dom'; import { NavLink, useNavigate } from 'react-router-dom';
import { ChevronRight } from 'lucide-react'; import { ChevronRight } from 'lucide-react';
import { useHomeStore } from '../store/homeStore'; import { useHomeStore } from '../store/homeStore';
import { useAuthStore } from '../store/authStore';
export default function Home() { export default function Home() {
const homeSections = useHomeStore(s => s.sections); const homeSections = useHomeStore(s => s.sections);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const isVisible = (id: string) => homeSections.find(s => s.id === id)?.visible ?? true; const isVisible = (id: string) => homeSections.find(s => s.id === id)?.visible ?? true;
const [starred, setStarred] = useState<SubsonicAlbum[]>([]); const [starred, setStarred] = useState<SubsonicAlbum[]>([]);
@@ -44,7 +46,7 @@ export default function Home() {
setRandomArtists(shuffled.slice(0, 16)); setRandomArtists(shuffled.slice(0, 16));
setLoading(false); setLoading(false);
}).catch(() => setLoading(false)); }).catch(() => setLoading(false));
}, []); }, [musicLibraryFilterVersion, homeSections]);
const loadMore = async ( const loadMore = async (
type: 'starred' | 'newest' | 'random' | 'frequent' | 'recent', type: 'starred' | 'newest' | 'random' | 'frequent' | 'recent',
+3 -1
View File
@@ -4,6 +4,7 @@ import { ChevronLeft } from 'lucide-react';
import AlbumCard from '../components/AlbumCard'; import AlbumCard from '../components/AlbumCard';
import { search, SubsonicAlbum } from '../api/subsonic'; import { search, SubsonicAlbum } from '../api/subsonic';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/authStore';
export default function LabelAlbums() { export default function LabelAlbums() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -11,6 +12,7 @@ export default function LabelAlbums() {
const navigate = useNavigate(); const navigate = useNavigate();
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]); const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
useEffect(() => { useEffect(() => {
if (!name) return; if (!name) return;
@@ -31,7 +33,7 @@ export default function LabelAlbums() {
}) })
.catch(console.error) .catch(console.error)
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [name]); }, [name, musicLibraryFilterVersion]);
return ( return (
<div className="animate-fade-in" style={{ padding: '0 var(--space-6)' }}> <div className="animate-fade-in" style={{ padding: '0 var(--space-6)' }}>
+3 -1
View File
@@ -3,6 +3,7 @@ import AlbumCard from '../components/AlbumCard';
import GenreFilterBar from '../components/GenreFilterBar'; import GenreFilterBar from '../components/GenreFilterBar';
import { getAlbumList, getAlbumsByGenre, SubsonicAlbum } from '../api/subsonic'; import { getAlbumList, getAlbumsByGenre, SubsonicAlbum } from '../api/subsonic';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/authStore';
const PAGE_SIZE = 30; const PAGE_SIZE = 30;
@@ -15,6 +16,7 @@ async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> {
export default function NewReleases() { export default function NewReleases() {
const { t } = useTranslation(); const { t } = useTranslation();
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]); const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
@@ -43,7 +45,7 @@ export default function NewReleases() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, [musicLibraryFilterVersion]);
useEffect(() => { useEffect(() => {
if (filtered) loadFiltered(selectedGenres); if (filtered) loadFiltered(selectedGenres);
+3 -1
View File
@@ -4,6 +4,7 @@ import { getAlbumList, getAlbumsByGenre, SubsonicAlbum } from '../api/subsonic';
import AlbumCard from '../components/AlbumCard'; import AlbumCard from '../components/AlbumCard';
import GenreFilterBar from '../components/GenreFilterBar'; import GenreFilterBar from '../components/GenreFilterBar';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/authStore';
const ALBUM_COUNT = 30; const ALBUM_COUNT = 30;
@@ -21,6 +22,7 @@ async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> {
export default function RandomAlbums() { export default function RandomAlbums() {
const { t } = useTranslation(); const { t } = useTranslation();
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]); const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [selectedGenres, setSelectedGenres] = useState<string[]>([]); const [selectedGenres, setSelectedGenres] = useState<string[]>([]);
@@ -42,7 +44,7 @@ export default function RandomAlbums() {
loadingRef.current = false; loadingRef.current = false;
setLoading(false); setLoading(false);
} }
}, []); }, [musicLibraryFilterVersion]);
useEffect(() => { load(selectedGenres); }, [selectedGenres, load]); useEffect(() => { load(selectedGenres); }, [selectedGenres, load]);
+2 -1
View File
@@ -36,6 +36,7 @@ export default function RandomMix() {
const psyDrag = useDragDrop(); const psyDrag = useDragDrop();
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set()); const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
const { excludeAudiobooks, setExcludeAudiobooks, customGenreBlacklist, setCustomGenreBlacklist } = useAuthStore(); const { excludeAudiobooks, setExcludeAudiobooks, customGenreBlacklist, setCustomGenreBlacklist } = useAuthStore();
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const [addedGenre, setAddedGenre] = useState<string | null>(null); const [addedGenre, setAddedGenre] = useState<string | null>(null);
const [addedArtist, setAddedArtist] = useState<string | null>(null); const [addedArtist, setAddedArtist] = useState<string | null>(null);
@@ -82,7 +83,7 @@ export default function RandomMix() {
setAllAvailableGenres(available); setAllAvailableGenres(available);
setDisplayedGenres(available.slice(0, 20)); setDisplayedGenres(available.slice(0, 20));
}).catch(() => {}); }).catch(() => {});
}, []); }, [musicLibraryFilterVersion]);
const filteredSongs = songs.filter(song => { const filteredSongs = songs.filter(song => {
if (!excludeAudiobooks) return true; if (!excludeAudiobooks) return true;
+3 -1
View File
@@ -7,6 +7,7 @@ import AlbumRow from '../components/AlbumRow';
import ArtistRow from '../components/ArtistRow'; import ArtistRow from '../components/ArtistRow';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useDragDrop } from '../contexts/DragDropContext'; import { useDragDrop } from '../contexts/DragDropContext';
import { useAuthStore } from '../store/authStore';
function formatDuration(s: number) { function formatDuration(s: number) {
return `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, '0')}`; return `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, '0')}`;
@@ -21,6 +22,7 @@ export default function SearchResults() {
const playTrack = usePlayerStore(s => s.playTrack); const playTrack = usePlayerStore(s => s.playTrack);
const currentTrack = usePlayerStore(s => s.currentTrack); const currentTrack = usePlayerStore(s => s.currentTrack);
const psyDrag = useDragDrop(); const psyDrag = useDragDrop();
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
useEffect(() => { useEffect(() => {
if (!query.trim()) { setResults(null); return; } if (!query.trim()) { setResults(null); return; }
@@ -28,7 +30,7 @@ export default function SearchResults() {
search(query, { artistCount: 20, albumCount: 20, songCount: 50 }) search(query, { artistCount: 20, albumCount: 20, songCount: 50 })
.then(r => setResults(r)) .then(r => setResults(r))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [query]); }, [query, musicLibraryFilterVersion]);
const hasResults = results && (results.artists.length || results.albums.length || results.songs.length); const hasResults = results && (results.artists.length || results.albums.length || results.songs.length);
+4 -3
View File
@@ -33,6 +33,7 @@ const PERIODS: { key: LastfmPeriod; label: string }[] = [
export default function Statistics() { export default function Statistics() {
const { t } = useTranslation(); const { t } = useTranslation();
const { lastfmSessionKey, lastfmUsername } = useAuthStore(); const { lastfmSessionKey, lastfmUsername } = useAuthStore();
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const [recent, setRecent] = useState<SubsonicAlbum[]>([]); const [recent, setRecent] = useState<SubsonicAlbum[]>([]);
const [frequent, setFrequent] = useState<SubsonicAlbum[]>([]); const [frequent, setFrequent] = useState<SubsonicAlbum[]>([]);
const [highest, setHighest] = useState<SubsonicAlbum[]>([]); const [highest, setHighest] = useState<SubsonicAlbum[]>([]);
@@ -73,7 +74,7 @@ export default function Statistics() {
setGenres(sorted); setGenres(sorted);
setLoading(false); setLoading(false);
}).catch(() => setLoading(false)); }).catch(() => setLoading(false));
}, []); }, [musicLibraryFilterVersion]);
// Background fetch: total playtime (paginate getAlbumList up to 10 pages of 500) // Background fetch: total playtime (paginate getAlbumList up to 10 pages of 500)
useEffect(() => { useEffect(() => {
@@ -102,7 +103,7 @@ export default function Statistics() {
} }
})(); })();
return () => { cancelled = true; }; return () => { cancelled = true; };
}, []); }, [musicLibraryFilterVersion]);
// Background fetch: format distribution (sample of 500 random songs) // Background fetch: format distribution (sample of 500 random songs)
useEffect(() => { useEffect(() => {
@@ -121,7 +122,7 @@ export default function Statistics() {
setFormatSampleSize(songs.length); setFormatSampleSize(songs.length);
}).catch(() => {}); }).catch(() => {});
return () => { cancelled = true; }; return () => { cancelled = true; };
}, []); }, [musicLibraryFilterVersion]);
useEffect(() => { useEffect(() => {
if (!lastfmIsConfigured() || !lastfmSessionKey || !lastfmUsername) return; if (!lastfmIsConfigured() || !lastfmSessionKey || !lastfmUsername) return;
+44 -2
View File
@@ -57,6 +57,16 @@ interface AuthState {
/** Parent directory; actual cache is `<dir>/psysonic-hot-cache/`. Empty = app data. */ /** Parent directory; actual cache is `<dir>/psysonic-hot-cache/`. Empty = app data. */
hotCacheDownloadDir: string; hotCacheDownloadDir: string;
/** Subsonic music folders for the active server (not persisted; refetched on login / server change). */
musicFolders: Array<{ id: string; name: string }>;
/**
* Per server: `all` = no musicFolderId param; otherwise a single folder id.
* Only one library or all no multi-folder merge.
*/
musicLibraryFilterByServer: Record<string, 'all' | string>;
/** Bumps when `setMusicLibraryFilter` runs so pages refetch catalog data. */
musicLibraryFilterVersion: number;
// Status // Status
isLoggedIn: boolean; isLoggedIn: boolean;
isConnecting: boolean; isConnecting: boolean;
@@ -103,6 +113,8 @@ interface AuthState {
setHotCacheMaxMb: (v: number) => void; setHotCacheMaxMb: (v: number) => void;
setHotCacheDebounceSec: (v: number) => void; setHotCacheDebounceSec: (v: number) => void;
setHotCacheDownloadDir: (v: string) => void; setHotCacheDownloadDir: (v: string) => void;
setMusicFolders: (folders: Array<{ id: string; name: string }>) => void;
setMusicLibraryFilter: (folderId: 'all' | string) => void;
logout: () => void; logout: () => void;
// Derived // Derived
@@ -151,6 +163,9 @@ export const useAuthStore = create<AuthState>()(
hotCacheMaxMb: 256, hotCacheMaxMb: 256,
hotCacheDebounceSec: 30, hotCacheDebounceSec: 30,
hotCacheDownloadDir: '', hotCacheDownloadDir: '',
musicFolders: [],
musicLibraryFilterByServer: {},
musicLibraryFilterVersion: 0,
isLoggedIn: false, isLoggedIn: false,
isConnecting: false, isConnecting: false,
connectionError: null, connectionError: null,
@@ -180,7 +195,7 @@ export const useAuthStore = create<AuthState>()(
}); });
}, },
setActiveServer: (id) => set({ activeServerId: id }), setActiveServer: (id) => set({ activeServerId: id, musicFolders: [] }),
setLoggedIn: (v) => set({ isLoggedIn: v }), setLoggedIn: (v) => set({ isLoggedIn: v }),
setConnecting: (v) => set({ isConnecting: v }), setConnecting: (v) => set({ isConnecting: v }),
@@ -233,7 +248,30 @@ export const useAuthStore = create<AuthState>()(
setHotCacheDebounceSec: (v) => set({ hotCacheDebounceSec: v }), setHotCacheDebounceSec: (v) => set({ hotCacheDebounceSec: v }),
setHotCacheDownloadDir: (v) => set({ hotCacheDownloadDir: v }), setHotCacheDownloadDir: (v) => set({ hotCacheDownloadDir: v }),
logout: () => set({ isLoggedIn: false }), setMusicFolders: (folders) => {
const sid = get().activeServerId;
set(s => {
const f = sid ? s.musicLibraryFilterByServer[sid] : undefined;
const invalidFilter = f && f !== 'all' && !folders.some(x => x.id === f);
return {
musicFolders: folders,
...(sid && invalidFilter
? { musicLibraryFilterByServer: { ...s.musicLibraryFilterByServer, [sid]: 'all' } }
: {}),
};
});
},
setMusicLibraryFilter: (folderId) => {
const sid = get().activeServerId;
if (!sid) return;
set(s => ({
musicLibraryFilterByServer: { ...s.musicLibraryFilterByServer, [sid]: folderId },
musicLibraryFilterVersion: s.musicLibraryFilterVersion + 1,
}));
},
logout: () => set({ isLoggedIn: false, musicFolders: [] }),
getBaseUrl: () => { getBaseUrl: () => {
const s = get(); const s = get();
@@ -250,6 +288,10 @@ export const useAuthStore = create<AuthState>()(
{ {
name: 'psysonic-auth', name: 'psysonic-auth',
storage: createJSONStorage(() => localStorage), storage: createJSONStorage(() => localStorage),
partialize: state => {
const { musicFolders: _mf, musicLibraryFilterVersion: _fv, ...rest } = state;
return rest;
},
} }
) )
); );
+159
View File
@@ -93,6 +93,165 @@
overflow-y: auto; overflow-y: auto;
} }
/* Library scope: Navidrome-style dropdown trigger + fixed panel (portal) */
.nav-library-scope-trigger {
display: flex;
align-items: flex-start;
gap: var(--space-2);
width: calc(100% - 2 * var(--space-3));
margin: var(--space-3) var(--space-3) var(--space-2);
padding: var(--space-2) var(--space-2);
border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.08));
border-radius: var(--radius-md);
background: var(--bg-tertiary, rgba(0, 0, 0, 0.2));
color: var(--text-secondary);
cursor: pointer;
text-align: left;
transition: background var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast);
}
.nav-library-scope-trigger:hover {
background: var(--bg-hover);
color: var(--text-primary);
border-color: var(--border-default, rgba(255, 255, 255, 0.12));
}
/* «Все библиотеки»: как секция навигации, без рамки и подложки */
.nav-library-scope-trigger--plain {
border-color: transparent;
background: transparent;
align-items: center;
}
.nav-library-scope-trigger--plain:hover {
border-color: transparent;
}
.nav-library-scope-trigger--plain .nav-library-scope-chevron {
margin-top: 0;
}
.nav-library-scope-trigger--open:not(.nav-library-scope-trigger--plain) {
border-color: var(--accent, var(--border-default));
}
.nav-library-scope-trigger--open.nav-library-scope-trigger--plain {
border-color: var(--border-subtle, rgba(255, 255, 255, 0.08));
background: var(--bg-tertiary, rgba(0, 0, 0, 0.2));
}
.nav-library-scope-icon {
flex-shrink: 0;
margin-top: 2px;
opacity: 0.85;
}
.nav-library-scope-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.nav-library-scope-title {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-muted);
}
.nav-library-scope-subtitle {
font-size: 12px;
font-weight: 500;
color: var(--text-primary);
line-height: 1.25;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nav-library-scope-chevron {
flex-shrink: 0;
margin-top: 2px;
opacity: 0.7;
transition: transform var(--transition-fast);
}
.nav-library-scope-trigger--open .nav-library-scope-chevron {
transform: rotate(180deg);
}
.nav-library-dropdown-panel {
z-index: 10050;
display: flex;
flex-direction: column;
padding: var(--space-1);
border-radius: var(--radius-md);
border: 1px solid var(--border-dropdown, rgba(255, 255, 255, 0.12));
background: var(--bg-secondary, #1e1e2e);
box-shadow: 0 8px 24px var(--shadow-dropdown, rgba(0, 0, 0, 0.45));
box-sizing: border-box;
overflow-y: visible;
}
/* >10 папок: «Все библиотеки» + до 10 строк папок, дальше — прокрутка */
.nav-library-dropdown-panel--many-libraries {
--nav-lib-dropdown-row-h: calc(2 * var(--space-2) + 1.35rem);
max-height: min(
calc(11 * var(--nav-lib-dropdown-row-h) + 2 * var(--space-1)),
70vh
);
overflow-y: auto;
}
.nav-library-dropdown-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
width: 100%;
margin: 0;
padding: var(--space-2) var(--space-3);
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-primary);
font-size: 13px;
font-weight: 500;
text-align: left;
cursor: pointer;
transition: background var(--transition-fast);
}
.nav-library-dropdown-item:hover {
background: var(--bg-hover);
}
.nav-library-dropdown-item--selected {
color: var(--accent);
}
.nav-library-dropdown-item-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nav-library-dropdown-check {
flex-shrink: 0;
color: var(--accent);
}
.nav-library-dropdown-check-spacer {
flex-shrink: 0;
width: 16px;
height: 16px;
}
.nav-section-label { .nav-section-label {
font-size: 10px; font-size: 10px;
font-weight: 600; font-weight: 600;