From f08619fb3dc52e93f9e424ba52de55fad124519e Mon Sep 17 00:00:00 2001 From: Maxim Isaev Date: Tue, 7 Apr 2026 13:59:59 +0300 Subject: [PATCH] feat(subsonic): per-server music folder filter and sidebar picker Apply musicFolderId across Subsonic requests, bump a filter version so library views reload, and add a fixed-position sidebar dropdown (with capped height when there are many folders). --- src/App.tsx | 20 ++++ src/api/subsonic.ts | 58 ++++++++- src/components/Hero.tsx | 4 +- src/components/LiveSearch.tsx | 4 +- src/components/MobileSearchOverlay.tsx | 4 +- src/components/Sidebar.tsx | 147 ++++++++++++++++++++++- src/locales/de.ts | 3 + src/locales/en.ts | 3 + src/locales/fr.ts | 3 + src/locales/nb.ts | 3 + src/locales/nl.ts | 3 + src/locales/ru.ts | 3 + src/locales/zh.ts | 3 + src/pages/AdvancedSearch.tsx | 8 +- src/pages/Albums.tsx | 6 +- src/pages/ArtistDetail.tsx | 5 +- src/pages/Artists.tsx | 3 +- src/pages/Favorites.tsx | 4 +- src/pages/GenreDetail.tsx | 2 + src/pages/Genres.tsx | 4 +- src/pages/Home.tsx | 4 +- src/pages/LabelAlbums.tsx | 4 +- src/pages/NewReleases.tsx | 4 +- src/pages/RandomAlbums.tsx | 4 +- src/pages/RandomMix.tsx | 3 +- src/pages/SearchResults.tsx | 4 +- src/pages/Statistics.tsx | 7 +- src/store/authStore.ts | 44 ++++++- src/styles/layout.css | 159 +++++++++++++++++++++++++ 29 files changed, 488 insertions(+), 35 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 494f490b..8f0cae2d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -53,6 +53,7 @@ import AppUpdater from './components/AppUpdater'; import { version } from '../package.json'; import { useConnectionStatus } from './hooks/useConnectionStatus'; import { useAuthStore } from './store/authStore'; +import { getMusicFolders } from './api/subsonic'; import { useOfflineStore } from './store/offlineStore'; import { initHotCachePrefetch } from './hotCachePrefetch'; import { usePlayerStore, initAudioListeners } from './store/playerStore'; @@ -82,9 +83,28 @@ function AppShell() { const navigate = useNavigate(); const location = useLocation(); 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 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 const prevConnStatus = useRef(connStatus); useEffect(() => { diff --git a/src/api/subsonic.ts b/src/api/subsonic.ts index 1dc007ef..fe53197e 100644 --- a/src/api/subsonic.ts +++ b/src/api/subsonic.ts @@ -40,6 +40,15 @@ async function api(endpoint: string, extra: Record = {}, tim return data as T; } +/** Optional `musicFolderId` when the user narrowed browsing to one Subsonic library (see `getMusicFolders`). */ +export function libraryFilterParams(): Record { + const { activeServerId, musicLibraryFilterByServer } = useAuthStore.getState(); + if (!activeServerId) return {}; + const f = musicLibraryFilterByServer[activeServerId]; + if (f === undefined || f === 'all') return {}; + return { musicFolderId: f }; +} + // ─── Types ──────────────────────────────────────────────────── export interface SubsonicAlbum { id: string; @@ -139,6 +148,11 @@ export interface SubsonicGenre { albumCount: number; } +export interface SubsonicMusicFolder { + id: string; + name: string; +} + export interface SubsonicArtistInfo { biography?: string; musicBrainzId?: string; @@ -150,6 +164,19 @@ export interface SubsonicArtistInfo { } // ─── API Methods ────────────────────────────────────────────── +export async function getMusicFolders(): Promise { + 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 { try { await api('ping.view'); @@ -178,7 +205,11 @@ export async function pingWithCredentials(serverUrl: string, username: string, p } export async function getRandomAlbums(size = 6): Promise { - 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 ?? []; } @@ -188,12 +219,19 @@ export async function getAlbumList( offset = 0, extra: Record = {} ): Promise { - 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 ?? []; } export async function getRandomSongs(size = 50, genre?: string, timeout = 15000): Promise { - const params: Record = { size, _t: Date.now() }; + const params: Record = { size, _t: Date.now(), ...libraryFilterParams() }; if (genre) params.genre = genre; const data = await api<{ randomSongs: { song: SubsonicSong[] } }>('getRandomSongs.view', params, timeout); return data.randomSongs?.song ?? []; @@ -215,7 +253,9 @@ export async function getAlbum(id: string): Promise<{ album: SubsonicAlbum; song } export async function getArtists(): Promise { - 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 ?? []; return indices.flatMap(i => i.artist ?? []); } @@ -258,7 +298,12 @@ export async function getGenres(): Promise { export async function getAlbumsByGenre(genre: string, size = 50, offset = 0): Promise { 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; if (!raw) return []; @@ -284,7 +329,7 @@ export async function getStarred(): Promise { album?: SubsonicAlbum[]; song?: SubsonicSong[]; } - }>('getStarred2.view'); + }>('getStarred2.view', { ...libraryFilterParams() }); const r = data.starred2 ?? {}; 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, albumCount: options?.albumCount ?? 5, songCount: options?.songCount ?? 10, + ...libraryFilterParams(), }); const r = data.searchResult3 ?? {}; return { artists: r.artist ?? [], albums: r.album ?? [], songs: r.song ?? [] }; diff --git a/src/components/Hero.tsx b/src/components/Hero.tsx index e28abcfd..7d5ab9d1 100644 --- a/src/components/Hero.tsx +++ b/src/components/Hero.tsx @@ -7,6 +7,7 @@ import { usePlayerStore, songToTrack } from '../store/playerStore'; import { useTranslation } from 'react-i18next'; import { playAlbum } from '../utils/playAlbum'; import { useIsMobile } from '../hooks/useIsMobile'; +import { useAuthStore } from '../store/authStore'; const INTERVAL_MS = 10000; @@ -52,6 +53,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) { const { t } = useTranslation(); const navigate = useNavigate(); const isMobile = useIsMobile(); + const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); const [albums, setAlbums] = useState([]); const [activeIdx, setActiveIdx] = useState(0); const timerRef = useRef | null>(null); @@ -59,7 +61,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) { useEffect(() => { if (albumsProp?.length) { setAlbums(albumsProp); return; } getRandomAlbums(8).then(a => { if (a.length) setAlbums(a); }).catch(() => {}); - }, [albumsProp]); + }, [albumsProp, musicLibraryFilterVersion]); // Start / restart auto-advance timer const startTimer = useCallback((len: number) => { diff --git a/src/components/LiveSearch.tsx b/src/components/LiveSearch.tsx index 363660f7..7efac64a 100644 --- a/src/components/LiveSearch.tsx +++ b/src/components/LiveSearch.tsx @@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'; import { Search, Disc3, Users, Music, SlidersHorizontal } from 'lucide-react'; import { search, SearchResults, buildCoverArtUrl } from '../api/subsonic'; import { usePlayerStore, songToTrack } from '../store/playerStore'; +import { useAuthStore } from '../store/authStore'; import { useTranslation } from 'react-i18next'; 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 ref = useRef(null); const dropdownRef = useRef(null); + const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); const doSearch = useCallback( debounce(async (q: string) => { @@ -37,7 +39,7 @@ export default function LiveSearch() { setLoading(false); } }, 300), - [] + [musicLibraryFilterVersion] ); useEffect(() => { doSearch(query); setActiveIndex(-1); }, [query, doSearch]); diff --git a/src/components/MobileSearchOverlay.tsx b/src/components/MobileSearchOverlay.tsx index b27ddf05..df35e539 100644 --- a/src/components/MobileSearchOverlay.tsx +++ b/src/components/MobileSearchOverlay.tsx @@ -4,6 +4,7 @@ import { useNavigate } from 'react-router-dom'; import { X, Search, Disc3, Users, Music, Music2, Clock, ChevronRight } from 'lucide-react'; import { search, SearchResults, buildCoverArtUrl } from '../api/subsonic'; import { usePlayerStore, songToTrack } from '../store/playerStore'; +import { useAuthStore } from '../store/authStore'; import { useTranslation } from 'react-i18next'; const STORAGE_KEY = 'psysonic_recent_searches'; @@ -34,6 +35,7 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void } const [loading, setLoading] = useState(false); const [recentSearches, setRecentSearches] = useState(loadRecent); const inputRef = useRef(null); + const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); useEffect(() => { inputRef.current?.focus(); }, []); @@ -50,7 +52,7 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void } try { setResults(await search(q)); } finally { setLoading(false); } }, 300), - [] + [musicLibraryFilterVersion] ); useEffect(() => { doSearch(query); }, [query, doSearch]); diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 988567d5..569f1b7e 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -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 { useOfflineStore } from '../store/offlineStore'; import { useAuthStore } from '../store/authStore'; @@ -7,7 +8,8 @@ import { NavLink } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { 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'; import PsysonicLogo from './PsysonicLogo'; import PSmallLogo from './PSmallLogo'; @@ -44,8 +46,70 @@ export default function Sidebar({ const activeJobs = offlineJobs.filter(j => j.status === 'queued' || j.status === 'downloading'); 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 [libraryDropdownOpen, setLibraryDropdownOpen] = useState(false); + const [dropdownRect, setDropdownRect] = useState({ top: 0, left: 0, width: 0 }); + const libraryTriggerRef = useRef(null); + const showLibraryPicker = !isCollapsed && isLoggedIn && musicFolders.length > 1; + + const filterId = serverId ? (musicLibraryFilterByServer[serverId] ?? 'all') : 'all'; + const selectedFolderName = + filterId === 'all' ? null : musicFolders.find(f => f.id === filterId)?.name ?? null; + const libraryTriggerPlain = filterId === 'all'; + + const 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 const visibleLibrary = sidebarItems .filter(cfg => cfg.visible && ALL_NAV_ITEMS[cfg.id]?.section === 'library') @@ -73,8 +137,81 @@ export default function Sidebar({ {isCollapsed ? : } -