diff --git a/src/App.tsx b/src/App.tsx index d053d9c4..c2a01e6f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -34,6 +34,7 @@ import AdvancedSearch from './pages/AdvancedSearch'; import Playlists from './pages/Playlists'; import PlaylistDetail from './pages/PlaylistDetail'; import InternetRadio from './pages/InternetRadio'; +import FolderBrowser from './pages/FolderBrowser'; import NowPlayingPage from './pages/NowPlaying'; import FullscreenPlayer from './components/FullscreenPlayer'; import ContextMenu from './components/ContextMenu'; @@ -352,6 +353,7 @@ function AppShell() { } /> } /> } /> + } /> diff --git a/src/api/subsonic.ts b/src/api/subsonic.ts index 35bb5539..bc5caf9d 100644 --- a/src/api/subsonic.ts +++ b/src/api/subsonic.ts @@ -181,6 +181,68 @@ export interface SubsonicArtistInfo { } // ─── API Methods ────────────────────────────────────────────── +export interface SubsonicDirectoryEntry { + id: string; + parent?: string; + title: string; + isDir: boolean; + album?: string; + artist?: string; + albumId?: string; + artistId?: string; + coverArt?: string; + duration?: number; + track?: number; + year?: number; + bitRate?: number; + suffix?: string; + size?: number; + genre?: string; + starred?: string; + userRating?: number; +} + +export interface SubsonicDirectory { + id: string; + parent?: string; + name: string; + child: SubsonicDirectoryEntry[]; +} + +export async function getMusicDirectory(id: string): Promise { + const data = await api<{ directory: { id: string; parent?: string; name: string; child?: SubsonicDirectoryEntry | SubsonicDirectoryEntry[] } }>( + 'getMusicDirectory.view', + { id }, + ); + const dir = data.directory; + const raw = dir.child; + const child: SubsonicDirectoryEntry[] = !raw ? [] : Array.isArray(raw) ? raw : [raw]; + return { id: dir.id, parent: dir.parent, name: dir.name, child }; +} + +/** Returns the top-level artist/directory entries for a music folder root. + * Music folder IDs from getMusicFolders() are NOT valid getMusicDirectory IDs — + * use getIndexes.view with musicFolderId instead. */ +export async function getMusicIndexes(musicFolderId: string): Promise { + type IndexArtist = { id: string; name: string; coverArt?: string }; + type IndexEntry = { name: string; artist?: IndexArtist | IndexArtist[] }; + const data = await api<{ indexes: { index?: IndexEntry | IndexEntry[] } }>( + 'getIndexes.view', + { musicFolderId }, + ); + const raw = data.indexes?.index; + if (!raw) return []; + const indices = Array.isArray(raw) ? raw : [raw]; + const entries: SubsonicDirectoryEntry[] = []; + for (const idx of indices) { + const artists = idx.artist ? (Array.isArray(idx.artist) ? idx.artist : [idx.artist]) : []; + for (const a of artists) { + entries.push({ id: a.id, title: a.name, isDir: true, coverArt: a.coverArt }); + } + } + return entries; +} + export async function getMusicFolders(): Promise { const data = await api<{ musicFolders: { musicFolder: SubsonicMusicFolder | SubsonicMusicFolder[] } }>( 'getMusicFolders.view', diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index a5b6faa0..3eb75150 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -9,7 +9,7 @@ import { useTranslation } from 'react-i18next'; import { Disc3, Users, Music4, Radio, Settings, Heart, BarChart3, Shuffle, PanelLeftClose, PanelLeft, HelpCircle, Dices, AudioLines, HardDriveDownload, Tags, ListMusic, Cast, - ChevronDown, Check, Music2, TrendingUp, + ChevronDown, Check, Music2, TrendingUp, FolderOpen, } from 'lucide-react'; import PsysonicLogo from './PsysonicLogo'; import PSmallLogo from './PSmallLogo'; @@ -28,6 +28,7 @@ export const ALL_NAV_ITEMS: Record([]); + const wrapperRef = useRef(null); + const playTrack = usePlayerStore(s => s.playTrack); + + // ── root: load music folders on mount ───────────────────────────────────── + useEffect(() => { + const placeholder: Column = { id: 'root', name: '', items: [], selectedId: null, loading: true, error: false }; + setColumns([placeholder]); + getMusicFolders() + .then(folders => { + const items: SubsonicDirectoryEntry[] = folders.map(f => ({ + id: f.id, + title: f.name, + isDir: true, + })); + setColumns([{ ...placeholder, items, loading: false }]); + }) + .catch(() => { + setColumns([{ ...placeholder, items: [], loading: false, error: true }]); + }); + }, []); + + // ── auto-scroll to newly added column ───────────────────────────────────── + useEffect(() => { + const el = wrapperRef.current; + if (!el) return; + requestAnimationFrame(() => { el.scrollLeft = el.scrollWidth; }); + }, [columns.length]); + + // ── click a directory ────────────────────────────────────────────────────── + const handleDirClick = useCallback((colIndex: number, item: SubsonicDirectoryEntry) => { + // Mark selected + truncate columns after this one + add loading column + setColumns(prev => [ + ...prev.slice(0, colIndex + 1).map((c, i) => + i === colIndex ? { ...c, selectedId: item.id } : c, + ), + { id: item.id, name: item.title, items: [], selectedId: null, loading: true, error: false }, + ]); + + // Column 0 holds music folder roots — their IDs are only valid for + // getIndexes.view (musicFolderId), not getMusicDirectory.view + const fetchItems = colIndex === 0 + ? getMusicIndexes(item.id) + : getMusicDirectory(item.id).then(d => d.child); + + fetchItems + .then(items => { + setColumns(prev => { + const idx = prev.findIndex(c => c.id === item.id && c.loading); + if (idx === -1) return prev; + const next = [...prev]; + next[idx] = { ...next[idx], items, loading: false }; + return next; + }); + }) + .catch(() => { + setColumns(prev => { + const idx = prev.findIndex(c => c.id === item.id && c.loading); + if (idx === -1) return prev; + const next = [...prev]; + next[idx] = { ...next[idx], loading: false, error: true }; + return next; + }); + }); + }, []); + + // ── click a file (track) ─────────────────────────────────────────────────── + const handleFileClick = useCallback((colIndex: number, item: SubsonicDirectoryEntry) => { + setColumns(prev => prev.map((c, i) => + i === colIndex ? { ...c, selectedId: item.id } : c, + )); + // Build queue from all tracks in this column + const col = columns[colIndex]; + const queue = col.items.filter(it => !it.isDir).map(entryToTrack); + playTrack(entryToTrack(item), queue.length > 0 ? queue : [entryToTrack(item)]); + }, [columns, playTrack]); + + // ── render ───────────────────────────────────────────────────────────────── + return ( +
+

{t('sidebar.folderBrowser')}

+
+ {columns.map((col, colIndex) => ( +
+ {col.loading ? ( +
+
+
+ ) : col.error ? ( +
+ {t('folderBrowser.error')} +
+ ) : col.items.length === 0 ? ( +
{t('folderBrowser.empty')}
+ ) : ( + col.items.map(item => { + const isSelected = col.selectedId === item.id; + return ( + + ); + }) + )} +
+ ))} +
+
+ ); +} diff --git a/src/store/sidebarStore.ts b/src/store/sidebarStore.ts index db58ee72..47f7b282 100644 --- a/src/store/sidebarStore.ts +++ b/src/store/sidebarStore.ts @@ -20,6 +20,7 @@ export const DEFAULT_SIDEBAR_ITEMS: SidebarItemConfig[] = [ { id: 'playlists', visible: true }, { id: 'mostPlayed', visible: true }, { id: 'radio', visible: true }, + { id: 'folderBrowser', visible: false }, { id: 'statistics', visible: true }, { id: 'help', visible: true }, ]; diff --git a/src/styles/components.css b/src/styles/components.css index 20486f85..ef62f6c0 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -6767,3 +6767,111 @@ transform: scale(1.2); box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 20%, transparent); } + +/* ── Folder Browser (Miller Columns) ──────────────────────────────────────── */ + +.folder-browser { + display: flex; + flex-direction: column; + padding: 0; + height: 100%; +} + +.folder-browser-title { + padding: 1.5rem 1.5rem 0.75rem; + flex-shrink: 0; +} + +.folder-browser-columns { + flex: 1; + min-height: 0; + display: flex; + flex-direction: row; + overflow-x: auto; + overflow-y: hidden; + /* height fallback for browsers that don't support flex: 1 correctly here */ + height: calc(100vh - var(--player-height, 88px) - var(--titlebar-height, 0px) - 70px); + border-top: 1px solid var(--border-subtle); +} + +.folder-browser-columns::-webkit-scrollbar { + height: 6px; +} + +.folder-col { + flex: 1; + min-width: 200px; + height: 100%; + overflow-y: auto; + overflow-x: hidden; + border-right: 1px solid var(--border-subtle); +} + +.folder-col-status { + display: flex; + align-items: center; + justify-content: center; + height: 80px; + color: var(--text-muted); + font-size: 0.8rem; + padding: 1rem; + text-align: center; +} + +.folder-col-error { + color: var(--danger, #f38ba8); +} + +.folder-col-row { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + padding: 0.38rem 0.6rem 0.38rem 0.75rem; + background: none; + border: none; + border-radius: 0; + cursor: pointer; + color: var(--text-primary); + font-size: 0.8rem; + text-align: left; + transition: background 0.1s; +} + +.folder-col-row:hover { + background: var(--bg-hover, color-mix(in srgb, var(--accent) 8%, transparent)); +} + +.folder-col-row.selected { + background: var(--accent); + color: #fff; +} + +.folder-col-row.selected .folder-col-chevron { + color: rgba(255, 255, 255, 0.7); +} + +.folder-col-icon { + flex-shrink: 0; + opacity: 0.75; + display: flex; + align-items: center; +} + +.folder-col-row.selected .folder-col-icon { + opacity: 1; +} + +.folder-col-name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.folder-col-chevron { + flex-shrink: 0; + color: var(--text-muted); + margin-left: auto; +}