feat(folder-browser): Miller columns directory navigation

New page /folders with macOS Finder-style column layout:
- getMusicDirectory / getMusicIndexes / getMusicFolders Subsonic API methods
- Root level uses getIndexes.view (music folder IDs are not getMusicDirectory IDs)
- Columns fill available width with flex: 1, min-width: 200px
- Auto-scroll to rightmost column on expand
- Cancelled-flag prevents stale state on fast navigation
- Clicking a track plays it in the context of the current column's tracks
- FolderOpen/Folder/Music icons, accent-colored selection, ellipsis truncation
- Hidden in sidebar by default (user can enable in Settings)
- i18n: EN DE FR NL NB ZH RU

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-08 14:40:29 +02:00
parent 0a0dde057d
commit 5848c621fd
13 changed files with 387 additions and 7 deletions
+2
View File
@@ -34,6 +34,7 @@ import AdvancedSearch from './pages/AdvancedSearch';
import Playlists from './pages/Playlists'; import Playlists from './pages/Playlists';
import PlaylistDetail from './pages/PlaylistDetail'; import PlaylistDetail from './pages/PlaylistDetail';
import InternetRadio from './pages/InternetRadio'; import InternetRadio from './pages/InternetRadio';
import FolderBrowser from './pages/FolderBrowser';
import NowPlayingPage from './pages/NowPlaying'; import NowPlayingPage from './pages/NowPlaying';
import FullscreenPlayer from './components/FullscreenPlayer'; import FullscreenPlayer from './components/FullscreenPlayer';
import ContextMenu from './components/ContextMenu'; import ContextMenu from './components/ContextMenu';
@@ -352,6 +353,7 @@ function AppShell() {
<Route path="/playlists" element={<Playlists />} /> <Route path="/playlists" element={<Playlists />} />
<Route path="/playlists/:id" element={<PlaylistDetail />} /> <Route path="/playlists/:id" element={<PlaylistDetail />} />
<Route path="/radio" element={<InternetRadio />} /> <Route path="/radio" element={<InternetRadio />} />
<Route path="/folders" element={<FolderBrowser />} />
</Routes> </Routes>
</div> </div>
</main> </main>
+62
View File
@@ -181,6 +181,68 @@ export interface SubsonicArtistInfo {
} }
// ─── API Methods ────────────────────────────────────────────── // ─── 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<SubsonicDirectory> {
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<SubsonicDirectoryEntry[]> {
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<SubsonicMusicFolder[]> { export async function getMusicFolders(): Promise<SubsonicMusicFolder[]> {
const data = await api<{ musicFolders: { musicFolder: SubsonicMusicFolder | SubsonicMusicFolder[] } }>( const data = await api<{ musicFolders: { musicFolder: SubsonicMusicFolder | SubsonicMusicFolder[] } }>(
'getMusicFolders.view', 'getMusicFolders.view',
+2 -1
View File
@@ -9,7 +9,7 @@ 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, TrendingUp, ChevronDown, Check, Music2, TrendingUp, FolderOpen,
} from 'lucide-react'; } from 'lucide-react';
import PsysonicLogo from './PsysonicLogo'; import PsysonicLogo from './PsysonicLogo';
import PSmallLogo from './PSmallLogo'; import PSmallLogo from './PSmallLogo';
@@ -28,6 +28,7 @@ export const ALL_NAV_ITEMS: Record<string, { icon: React.ElementType; labelKey:
playlists: { icon: ListMusic, labelKey: 'sidebar.playlists', to: '/playlists', section: 'library' }, playlists: { icon: ListMusic, labelKey: 'sidebar.playlists', to: '/playlists', section: 'library' },
mostPlayed: { icon: TrendingUp, labelKey: 'sidebar.mostPlayed', to: '/most-played', section: 'library' }, mostPlayed: { icon: TrendingUp, labelKey: 'sidebar.mostPlayed', to: '/most-played', section: 'library' },
radio: { icon: Cast, labelKey: 'sidebar.radio', to: '/radio', section: 'library' }, radio: { icon: Cast, labelKey: 'sidebar.radio', to: '/radio', section: 'library' },
folderBrowser: { icon: FolderOpen, labelKey: 'sidebar.folderBrowser', to: '/folders', section: 'library' },
statistics: { icon: BarChart3, labelKey: 'sidebar.statistics', to: '/statistics', section: 'system' }, statistics: { icon: BarChart3, labelKey: 'sidebar.statistics', to: '/statistics', section: 'system' },
help: { icon: HelpCircle, labelKey: 'sidebar.help', to: '/help', section: 'system' }, help: { icon: HelpCircle, labelKey: 'sidebar.help', to: '/help', section: 'system' },
}; };
+6 -1
View File
@@ -21,6 +21,7 @@ export const deTranslation = {
playlists: 'Playlists', playlists: 'Playlists',
mostPlayed: 'Meistgehört', mostPlayed: 'Meistgehört',
radio: 'Internetradio', radio: 'Internetradio',
folderBrowser: 'Ordner-Browser',
libraryScope: 'Bibliotheksumfang', libraryScope: 'Bibliotheksumfang',
allLibraries: 'Alle Bibliotheken', allLibraries: 'Alle Bibliotheken',
}, },
@@ -880,5 +881,9 @@ export const deTranslation = {
favorite: 'Zu Favoriten hinzufügen', favorite: 'Zu Favoriten hinzufügen',
unfavorite: 'Aus Favoriten entfernen', unfavorite: 'Aus Favoriten entfernen',
noFavorites: 'Keine Lieblingssender.', noFavorites: 'Keine Lieblingssender.',
} },
folderBrowser: {
empty: 'Leerer Ordner',
error: 'Laden fehlgeschlagen',
},
}; };
+6 -1
View File
@@ -22,6 +22,7 @@ export const enTranslation = {
playlists: 'Playlists', playlists: 'Playlists',
mostPlayed: 'Most Played', mostPlayed: 'Most Played',
radio: 'Internet Radio', radio: 'Internet Radio',
folderBrowser: 'Folder Browser',
libraryScope: 'Library scope', libraryScope: 'Library scope',
allLibraries: 'All libraries', allLibraries: 'All libraries',
}, },
@@ -881,5 +882,9 @@ export const enTranslation = {
favorite: 'Add to favorites', favorite: 'Add to favorites',
unfavorite: 'Remove from favorites', unfavorite: 'Remove from favorites',
noFavorites: 'No favorite stations.', noFavorites: 'No favorite stations.',
} },
folderBrowser: {
empty: 'Empty folder',
error: 'Failed to load',
},
}; };
+6 -1
View File
@@ -21,6 +21,7 @@ export const frTranslation = {
playlists: 'Playlists', playlists: 'Playlists',
mostPlayed: 'Les plus joués', mostPlayed: 'Les plus joués',
radio: 'Radio Internet', radio: 'Radio Internet',
folderBrowser: 'Explorateur de dossiers',
libraryScope: 'Portée de la bibliothèque', libraryScope: 'Portée de la bibliothèque',
allLibraries: 'Toutes les bibliothèques', allLibraries: 'Toutes les bibliothèques',
}, },
@@ -875,5 +876,9 @@ export const frTranslation = {
favorite: 'Ajouter aux favoris', favorite: 'Ajouter aux favoris',
unfavorite: 'Retirer des favoris', unfavorite: 'Retirer des favoris',
noFavorites: 'Aucune station favorite.', noFavorites: 'Aucune station favorite.',
} },
folderBrowser: {
empty: 'Dossier vide',
error: 'Échec du chargement',
},
}; };
+6 -1
View File
@@ -21,6 +21,7 @@ export const nbTranslation = {
playlists: 'Spillelister', playlists: 'Spillelister',
mostPlayed: 'Mest spilt', mostPlayed: 'Mest spilt',
radio: 'Internettradio', radio: 'Internettradio',
folderBrowser: 'Mappeleser',
libraryScope: 'Biblioteksomfang', libraryScope: 'Biblioteksomfang',
allLibraries: 'Alle biblioteker', allLibraries: 'Alle biblioteker',
}, },
@@ -874,5 +875,9 @@ export const nbTranslation = {
favorite: 'Legg til i favoritter', favorite: 'Legg til i favoritter',
unfavorite: 'Fjern fra favoritter', unfavorite: 'Fjern fra favoritter',
noFavorites: 'Ingen favorittstasjoner.', noFavorites: 'Ingen favorittstasjoner.',
} },
folderBrowser: {
empty: 'Tom mappe',
error: 'Kunne ikke laste',
},
}; };
+6 -1
View File
@@ -21,6 +21,7 @@ export const nlTranslation = {
playlists: 'Playlists', playlists: 'Playlists',
mostPlayed: 'Meest gespeeld', mostPlayed: 'Meest gespeeld',
radio: 'Internetradio', radio: 'Internetradio',
folderBrowser: 'Mappenverkenner',
libraryScope: 'Bibliotheekbereik', libraryScope: 'Bibliotheekbereik',
allLibraries: 'Alle bibliotheken', allLibraries: 'Alle bibliotheken',
}, },
@@ -875,5 +876,9 @@ export const nlTranslation = {
favorite: 'Toevoegen aan favorieten', favorite: 'Toevoegen aan favorieten',
unfavorite: 'Verwijderen uit favorieten', unfavorite: 'Verwijderen uit favorieten',
noFavorites: 'Geen favoriete stations.', noFavorites: 'Geen favoriete stations.',
} },
folderBrowser: {
empty: 'Lege map',
error: 'Laden mislukt',
},
}; };
+5
View File
@@ -22,6 +22,7 @@ export const ruTranslation = {
playlists: 'Плейлисты', playlists: 'Плейлисты',
mostPlayed: 'Часто слушаемое', mostPlayed: 'Часто слушаемое',
radio: 'Онлайн-радио', radio: 'Онлайн-радио',
folderBrowser: 'Браузер папок',
libraryScope: 'Область медиатеки', libraryScope: 'Область медиатеки',
allLibraries: 'Все библиотеки', allLibraries: 'Все библиотеки',
}, },
@@ -933,4 +934,8 @@ export const ruTranslation = {
unfavorite: 'Убрать из избранного', unfavorite: 'Убрать из избранного',
noFavorites: 'Избранных станций нет.', noFavorites: 'Избранных станций нет.',
}, },
folderBrowser: {
empty: 'Папка пуста',
error: 'Ошибка загрузки',
},
}; };
+6 -1
View File
@@ -21,6 +21,7 @@ export const zhTranslation = {
playlists: '播放列表', playlists: '播放列表',
mostPlayed: '最常播放', mostPlayed: '最常播放',
radio: '网络电台', radio: '网络电台',
folderBrowser: '文件夹浏览器',
libraryScope: '资料库范围', libraryScope: '资料库范围',
allLibraries: '所有资料库', allLibraries: '所有资料库',
}, },
@@ -871,5 +872,9 @@ export const zhTranslation = {
favorite: '添加到收藏', favorite: '添加到收藏',
unfavorite: '从收藏移除', unfavorite: '从收藏移除',
noFavorites: '没有收藏的电台。', noFavorites: '没有收藏的电台。',
} },
folderBrowser: {
empty: '空文件夹',
error: '加载失败',
},
}; };
+171
View File
@@ -0,0 +1,171 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { getMusicFolders, getMusicDirectory, getMusicIndexes, SubsonicDirectoryEntry } from '../api/subsonic';
import { usePlayerStore, Track } from '../store/playerStore';
import { useTranslation } from 'react-i18next';
import { Folder, FolderOpen, Music, ChevronRight } from 'lucide-react';
// ── types ─────────────────────────────────────────────────────────────────────
type Column = {
id: string;
name: string;
items: SubsonicDirectoryEntry[];
selectedId: string | null;
loading: boolean;
error: boolean;
};
// ── helpers ───────────────────────────────────────────────────────────────────
function entryToTrack(e: SubsonicDirectoryEntry): Track {
return {
id: e.id,
title: e.title,
artist: e.artist ?? '',
album: e.album ?? '',
albumId: e.albumId ?? '',
artistId: e.artistId,
coverArt: e.coverArt,
duration: e.duration ?? 0,
track: e.track,
year: e.year,
bitRate: e.bitRate,
suffix: e.suffix,
genre: e.genre,
starred: e.starred,
userRating: e.userRating,
};
}
// ── component ─────────────────────────────────────────────────────────────────
export default function FolderBrowser() {
const { t } = useTranslation();
const [columns, setColumns] = useState<Column[]>([]);
const wrapperRef = useRef<HTMLDivElement>(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 (
<div className="folder-browser">
<h1 className="page-title folder-browser-title">{t('sidebar.folderBrowser')}</h1>
<div className="folder-browser-columns" ref={wrapperRef}>
{columns.map((col, colIndex) => (
<div key={`${col.id}-${colIndex}`} className="folder-col">
{col.loading ? (
<div className="folder-col-status">
<div className="spinner" style={{ width: 20, height: 20 }} />
</div>
) : col.error ? (
<div className="folder-col-status folder-col-error">
{t('folderBrowser.error')}
</div>
) : col.items.length === 0 ? (
<div className="folder-col-status">{t('folderBrowser.empty')}</div>
) : (
col.items.map(item => {
const isSelected = col.selectedId === item.id;
return (
<button
key={item.id}
className={`folder-col-row${isSelected ? ' selected' : ''}`}
onClick={() =>
item.isDir
? handleDirClick(colIndex, item)
: handleFileClick(colIndex, item)
}
>
<span className="folder-col-icon">
{item.isDir
? isSelected
? <FolderOpen size={14} />
: <Folder size={14} />
: <Music size={14} />}
</span>
<span className="folder-col-name">{item.title}</span>
{item.isDir && (
<ChevronRight size={12} className="folder-col-chevron" />
)}
</button>
);
})
)}
</div>
))}
</div>
</div>
);
}
+1
View File
@@ -20,6 +20,7 @@ export const DEFAULT_SIDEBAR_ITEMS: SidebarItemConfig[] = [
{ id: 'playlists', visible: true }, { id: 'playlists', visible: true },
{ id: 'mostPlayed', visible: true }, { id: 'mostPlayed', visible: true },
{ id: 'radio', visible: true }, { id: 'radio', visible: true },
{ id: 'folderBrowser', visible: false },
{ id: 'statistics', visible: true }, { id: 'statistics', visible: true },
{ id: 'help', visible: true }, { id: 'help', visible: true },
]; ];
+108
View File
@@ -6767,3 +6767,111 @@
transform: scale(1.2); transform: scale(1.2);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 20%, transparent); 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;
}