music: left panel is a drill-down list navigator (never a grid)

MusicBrowser now lists a folder's children as single-column list items and drills
via the shared channel (libraries → artists → albums). When the current path is an
album leaf it lists the album's siblings and highlights it, so you can switch
albums from the left while the right shows the tracklist. Handles the non-uniform
library layouts via the manifest's tracks count.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 13:05:30 +00:00
co-authored by Claude Opus 4.8
parent 6fab49eddc
commit e46b28e14e
@@ -1,26 +1,68 @@
import { useState, useEffect } from 'react';
import { useClient } from 'hooks/useClient';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { Library, Music2 } from 'lucide-react';
import { MUSIC_ROOT, MUSIC_CWD_CHANNEL, type LsResult } from './shared';
import { Library, Music2, ChevronLeft, Folder } from 'lucide-react';
import {
MUSIC_ROOT,
MUSIC_CWD_CHANNEL,
toRel,
type LsResult,
type Manifest,
type ManifestAlbum,
} from './shared';
// Left panel of the /music workspace — the library selector. Publishes the chosen path to the
// 'music:cwd' channel; MusicDetail (right panel) renders it.
// Left panel of the /music workspace — a single-column drill-down LIST navigator (libraries →
// artists → albums as list items; never a grid). Publishes the selected path to the 'music:cwd'
// channel; MusicDetail (right panel) renders the rich detail (covers/grids/tracklist).
export const MusicBrowser = () => {
const { get } = useClient();
const [cwd, setCwd] = usePanelChannel<string | null>(MUSIC_CWD_CHANNEL, null);
const [manifest, setManifest] = useState<Record<string, ManifestAlbum>>({});
const [libraries, setLibraries] = useState<string[]>([]);
const [folders, setFolders] = useState<string[]>([]);
useEffect(() => {
get<Manifest>('/music/manifest')
.then((m) => setManifest(m.albums))
.catch(() => setManifest({}));
get<LsResult>(`/file-browser/ls?path=${encodeURIComponent(MUSIC_ROOT)}`)
.then((r) => setLibraries(r.entries.filter((e) => e.type === 'directory').map((e) => e.name).sort()))
.catch(() => setLibraries([]));
}, []);
const currentLib = cwd ? cwd.split('/')[1] : null;
// The container folder whose children we list = the current folder, or its parent when the current
// path is an album leaf (so its siblings stay listed while the right shows the tracklist).
const rel = toRel(cwd);
const isAlbum = (manifest[rel]?.tracks ?? 0) > 0;
const navFolder = !cwd ? null : isAlbum ? cwd.split('/').slice(0, -1).join('/') : cwd;
const selected = cwd ? cwd.split('/').pop() : null;
useEffect(() => {
if (!navFolder) {
setFolders([]);
return;
}
let cancelled = false;
get<LsResult>(`/file-browser/ls?path=${encodeURIComponent(navFolder)}`)
.then((r) => {
if (!cancelled) setFolders(r.entries.filter((e) => e.type === 'directory').map((e) => e.name).sort());
})
.catch(() => {});
return () => {
cancelled = true;
};
}, [navFolder]);
const up = () => {
if (!navFolder) return;
const parts = navFolder.split('/');
setCwd(parts.length <= 2 ? null : parts.slice(0, -1).join('/'));
};
const crumbs = navFolder ? navFolder.slice(MUSIC_ROOT.length + 1).split('/') : [];
return (
<div className="flex h-full flex-col gap-1 overflow-y-auto p-3">
<div className="flex h-full flex-col overflow-y-auto p-3">
<button
type="button"
onClick={() => setCwd(null)}
@@ -29,22 +71,50 @@ export const MusicBrowser = () => {
<Music2 size={18} className="text-primary" />
<span className="font-semibold">Music</span>
</button>
<div className="flex items-center gap-2 px-2 pb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
<Library size={13} /> Libraries
</div>
{libraries.map((lib) => (
<button
key={lib}
type="button"
onClick={() => setCwd(`${MUSIC_ROOT}/${lib}`)}
className={`truncate rounded-md px-2 py-1.5 text-left text-sm ${
currentLib === lib ? 'bg-muted text-foreground' : 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'
}`}
>
{lib}
</button>
))}
{!libraries.length && <span className="px-2 text-sm text-muted-foreground">No libraries</span>}
{!navFolder ? (
<>
<div className="flex items-center gap-2 px-2 pb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
<Library size={13} /> Libraries
</div>
{libraries.map((lib) => (
<button
key={lib}
type="button"
onClick={() => setCwd(`${MUSIC_ROOT}/${lib}`)}
className="truncate rounded-md px-2 py-1.5 text-left text-sm text-muted-foreground hover:bg-muted/60 hover:text-foreground"
>
{lib}
</button>
))}
{!libraries.length && <span className="px-2 text-sm text-muted-foreground">No libraries</span>}
</>
) : (
<>
<button
type="button"
onClick={up}
className="mb-1 flex items-center gap-1 truncate px-2 py-1 text-left text-xs text-muted-foreground hover:text-foreground"
>
<ChevronLeft size={13} className="shrink-0" />
<span className="truncate">{crumbs.join(' / ')}</span>
</button>
{folders.map((f) => (
<button
key={f}
type="button"
onClick={() => setCwd(`${navFolder}/${f}`)}
className={`flex items-center gap-2 truncate rounded-md px-2 py-1.5 text-left text-sm ${
selected === f ? 'bg-muted text-foreground' : 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'
}`}
>
<Folder size={13} className="shrink-0 text-muted-foreground" />
<span className="truncate">{f}</span>
</button>
))}
{!folders.length && <span className="px-2 py-2 text-sm text-muted-foreground">No subfolders</span>}
</>
)}
</div>
);
};