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
+62
View File
@@ -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<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[]> {
const data = await api<{ musicFolders: { musicFolder: SubsonicMusicFolder | SubsonicMusicFolder[] } }>(
'getMusicFolders.view',