music (web): larger, spaced browser rows with cover thumbs; hide dotfiles
Left panel (MusicBrowser) polish + a shared fix: - Larger row text (text-base; "Music" heading text-lg). - More space between rows (gap-1.5 + py-2). - Leading thumbnail is the folder's indexed cover (its folder.jpg/cover.jpg, server-compressed) with a Folder/Library icon fallback when there's none or the image fails. - Hide hidden files/folders (dotfiles like .claude) in the listing — applied to MusicBrowser and the MusicDetail libraries/grid so neither panel shows them. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,21 +1,42 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, type ReactNode } from 'react';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||||
import { Library, Music2, ChevronLeft, Folder } from 'lucide-react';
|
import { Library, Music2, ChevronLeft, Folder } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
MUSIC_ROOT,
|
MUSIC_ROOT,
|
||||||
MUSIC_CWD_CHANNEL,
|
MUSIC_CWD_CHANNEL,
|
||||||
|
coverUrl,
|
||||||
toRel,
|
toRel,
|
||||||
type LsResult,
|
type LsResult,
|
||||||
type Manifest,
|
type Manifest,
|
||||||
type ManifestAlbum,
|
type ManifestAlbum,
|
||||||
} from './shared';
|
} from './shared';
|
||||||
|
|
||||||
|
// A row's leading thumbnail: the folder's indexed cover (its folder.jpg/cover.jpg, server-compressed),
|
||||||
|
// falling back to an icon when it has none or the image fails to load.
|
||||||
|
const RowThumb = ({ src, fallback }: { src: string | null; fallback: ReactNode }) => {
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
useEffect(() => setFailed(false), [src]);
|
||||||
|
return (
|
||||||
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded bg-muted">
|
||||||
|
{src && !failed ? (
|
||||||
|
<img src={src} alt="" className="h-full w-full object-cover" onError={() => setFailed(true)} />
|
||||||
|
) : (
|
||||||
|
fallback
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hidden files/folders (dotfiles like .claude, .git) never belong in the library listing.
|
||||||
|
const visibleDirs = (r: LsResult) =>
|
||||||
|
r.entries.filter((e) => e.type === 'directory' && !e.name.startsWith('.')).map((e) => e.name).sort();
|
||||||
|
|
||||||
// Left panel of the /music workspace — a single-column drill-down LIST navigator (libraries →
|
// 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'
|
// 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).
|
// channel; MusicDetail (right panel) renders the rich detail (covers/grids/tracklist).
|
||||||
export const MusicBrowser = () => {
|
export const MusicBrowser = () => {
|
||||||
const { get } = useClient();
|
const { get, token } = useClient();
|
||||||
const [cwd, setCwd] = usePanelChannel<string | null>(MUSIC_CWD_CHANNEL, null);
|
const [cwd, setCwd] = usePanelChannel<string | null>(MUSIC_CWD_CHANNEL, null);
|
||||||
const [manifest, setManifest] = useState<Record<string, ManifestAlbum>>({});
|
const [manifest, setManifest] = useState<Record<string, ManifestAlbum>>({});
|
||||||
const [libraries, setLibraries] = useState<string[]>([]);
|
const [libraries, setLibraries] = useState<string[]>([]);
|
||||||
@@ -26,7 +47,7 @@ export const MusicBrowser = () => {
|
|||||||
.then((m) => setManifest(m.albums))
|
.then((m) => setManifest(m.albums))
|
||||||
.catch(() => setManifest({}));
|
.catch(() => setManifest({}));
|
||||||
get<LsResult>(`/file-browser/ls?path=${encodeURIComponent(MUSIC_ROOT)}`)
|
get<LsResult>(`/file-browser/ls?path=${encodeURIComponent(MUSIC_ROOT)}`)
|
||||||
.then((r) => setLibraries(r.entries.filter((e) => e.type === 'directory').map((e) => e.name).sort()))
|
.then((r) => setLibraries(visibleDirs(r)))
|
||||||
.catch(() => setLibraries([]));
|
.catch(() => setLibraries([]));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -45,7 +66,7 @@ export const MusicBrowser = () => {
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
get<LsResult>(`/file-browser/ls?path=${encodeURIComponent(navFolder)}`)
|
get<LsResult>(`/file-browser/ls?path=${encodeURIComponent(navFolder)}`)
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
if (!cancelled) setFolders(r.entries.filter((e) => e.type === 'directory').map((e) => e.name).sort());
|
if (!cancelled) setFolders(visibleDirs(r));
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
return () => {
|
return () => {
|
||||||
@@ -60,6 +81,7 @@ export const MusicBrowser = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const crumbs = navFolder ? navFolder.slice(MUSIC_ROOT.length + 1).split('/') : [];
|
const crumbs = navFolder ? navFolder.slice(MUSIC_ROOT.length + 1).split('/') : [];
|
||||||
|
const coverFor = (childRel: string) => (manifest[childRel]?.cover ? coverUrl(childRel, token) : null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col overflow-y-auto p-3">
|
<div className="flex h-full flex-col overflow-y-auto p-3">
|
||||||
@@ -68,8 +90,8 @@ export const MusicBrowser = () => {
|
|||||||
onClick={() => setCwd(null)}
|
onClick={() => setCwd(null)}
|
||||||
className="flex items-center gap-2 px-2 pb-2 text-left text-foreground"
|
className="flex items-center gap-2 px-2 pb-2 text-left text-foreground"
|
||||||
>
|
>
|
||||||
<Music2 size={18} className="text-primary" />
|
<Music2 size={20} className="text-primary" />
|
||||||
<span className="font-semibold">Music</span>
|
<span className="text-lg font-semibold">Music</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{!navFolder ? (
|
{!navFolder ? (
|
||||||
@@ -77,17 +99,20 @@ export const MusicBrowser = () => {
|
|||||||
<div className="flex items-center gap-2 px-2 pb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
<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
|
<Library size={13} /> Libraries
|
||||||
</div>
|
</div>
|
||||||
{libraries.map((lib) => (
|
<div className="flex flex-col gap-1.5">
|
||||||
<button
|
{libraries.map((lib) => (
|
||||||
key={lib}
|
<button
|
||||||
type="button"
|
key={lib}
|
||||||
onClick={() => setCwd(`${MUSIC_ROOT}/${lib}`)}
|
type="button"
|
||||||
className="truncate rounded-md px-2 py-1.5 text-left text-sm text-muted-foreground hover:bg-muted/60 hover:text-foreground"
|
onClick={() => setCwd(`${MUSIC_ROOT}/${lib}`)}
|
||||||
>
|
className="flex items-center gap-3 truncate rounded-md px-2 py-2 text-left text-base text-muted-foreground hover:bg-muted/60 hover:text-foreground"
|
||||||
{lib}
|
>
|
||||||
</button>
|
<RowThumb src={coverFor(lib)} fallback={<Library size={18} className="text-muted-foreground" />} />
|
||||||
))}
|
<span className="truncate">{lib}</span>
|
||||||
{!libraries.length && <span className="px-2 text-sm text-muted-foreground">No libraries</span>}
|
</button>
|
||||||
|
))}
|
||||||
|
{!libraries.length && <span className="px-2 text-base text-muted-foreground">No libraries</span>}
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -99,20 +124,22 @@ export const MusicBrowser = () => {
|
|||||||
<ChevronLeft size={13} className="shrink-0" />
|
<ChevronLeft size={13} className="shrink-0" />
|
||||||
<span className="truncate">{crumbs.join(' / ')}</span>
|
<span className="truncate">{crumbs.join(' / ')}</span>
|
||||||
</button>
|
</button>
|
||||||
{folders.map((f) => (
|
<div className="flex flex-col gap-1.5">
|
||||||
<button
|
{folders.map((f) => (
|
||||||
key={f}
|
<button
|
||||||
type="button"
|
key={f}
|
||||||
onClick={() => setCwd(`${navFolder}/${f}`)}
|
type="button"
|
||||||
className={`flex items-center gap-2 truncate rounded-md px-2 py-1.5 text-left text-sm ${
|
onClick={() => setCwd(`${navFolder}/${f}`)}
|
||||||
selected === f ? 'bg-muted text-foreground' : 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'
|
className={`flex items-center gap-3 truncate rounded-md px-2 py-2 text-left text-base ${
|
||||||
}`}
|
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>
|
<RowThumb src={coverFor(toRel(`${navFolder}/${f}`))} fallback={<Folder size={18} className="text-muted-foreground" />} />
|
||||||
</button>
|
<span className="truncate">{f}</span>
|
||||||
))}
|
</button>
|
||||||
{!folders.length && <span className="px-2 py-2 text-sm text-muted-foreground">No subfolders</span>}
|
))}
|
||||||
|
{!folders.length && <span className="px-2 py-2 text-base text-muted-foreground">No subfolders</span>}
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export const MusicDetail = () => {
|
|||||||
.then((m) => setManifest(m.albums))
|
.then((m) => setManifest(m.albums))
|
||||||
.catch(() => setManifest({}));
|
.catch(() => setManifest({}));
|
||||||
get<LsResult>(`/file-browser/ls?path=${encodeURIComponent(MUSIC_ROOT)}`)
|
get<LsResult>(`/file-browser/ls?path=${encodeURIComponent(MUSIC_ROOT)}`)
|
||||||
.then((r) => setLibraries(r.entries.filter((e) => e.type === 'directory').map((e) => e.name).sort()))
|
.then((r) => setLibraries(r.entries.filter((e) => e.type === 'directory' && !e.name.startsWith('.')).map((e) => e.name).sort()))
|
||||||
.catch(() => setLibraries([]));
|
.catch(() => setLibraries([]));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -80,7 +80,7 @@ export const MusicDetail = () => {
|
|||||||
get<LsResult>(`/file-browser/ls?path=${encodeURIComponent(cwd)}`)
|
get<LsResult>(`/file-browser/ls?path=${encodeURIComponent(cwd)}`)
|
||||||
.then(async (r) => {
|
.then(async (r) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setFolders(r.entries.filter((e) => e.type === 'directory').map((e) => e.name).sort());
|
setFolders(r.entries.filter((e) => e.type === 'directory' && !e.name.startsWith('.')).map((e) => e.name).sort());
|
||||||
const audio = r.entries.filter((e) => e.type === 'file' && isAudio(e.name)).map((e) => e.name);
|
const audio = r.entries.filter((e) => e.type === 'file' && isAudio(e.name)).map((e) => e.name);
|
||||||
if (audio.length) {
|
if (audio.length) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user