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:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user