music (web): browser filter + reindex; richer album tracklist

Left panel (MusicBrowser):
- Fuzzy filter input (case-insensitive subsequence match) over the current
  library/folder list; resets on navigation, with a clear button + "No matches".
- Reindex button (spins while running) → POST /music/reindex, then refreshes the
  manifest and current listing.

Right panel album view (MusicDetail):
- Current track clearly highlighted: primary tint background + a Volume2 marker
  replacing the track number + medium weight.
- Every track shows its artist (falling back to album artist) under the title,
  and its duration on the right. Web Track type gains albumArtist + durationSec;
  fmtDuration/fuzzyMatch added to shared.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 12:52:34 +00:00
co-authored by Claude Opus 4.8
parent 3924155bfb
commit 5b17121de8
3 changed files with 115 additions and 26 deletions
@@ -1,11 +1,12 @@
import { useState, useEffect, type ReactNode } from 'react';
import { useClient } from 'hooks/useClient';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { Library, Music2, ChevronLeft, Folder } from 'lucide-react';
import { Library, Music2, ChevronLeft, Folder, Search, X, RefreshCw } from 'lucide-react';
import {
MUSIC_ROOT,
MUSIC_CWD_CHANNEL,
coverUrl,
fuzzyMatch,
toRel,
type LsResult,
type Manifest,
@@ -36,11 +37,13 @@ const visibleDirs = (r: LsResult) =>
// 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, token } = useClient();
const { get, post, token } = 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[]>([]);
const [query, setQuery] = useState('');
const [reindexing, setReindexing] = useState(false);
useEffect(() => {
get<Manifest>('/music/manifest')
@@ -74,6 +77,25 @@ export const MusicBrowser = () => {
};
}, [navFolder]);
// Start each folder unfiltered.
useEffect(() => setQuery(''), [navFolder]);
// Trigger a server-side library rebuild, then refresh the manifest + current listing.
const reindex = async () => {
if (reindexing) return;
setReindexing(true);
try {
await post('/music/reindex');
const m = await get<Manifest>('/music/manifest').catch(() => null);
if (m) setManifest(m.albums);
const target = navFolder ?? MUSIC_ROOT;
const r = await get<LsResult>(`/file-browser/ls?path=${encodeURIComponent(target)}`).catch(() => null);
if (r) navFolder ? setFolders(visibleDirs(r)) : setLibraries(visibleDirs(r));
} finally {
setReindexing(false);
}
};
const up = () => {
if (!navFolder) return;
const parts = navFolder.split('/');
@@ -82,6 +104,8 @@ export const MusicBrowser = () => {
const crumbs = navFolder ? navFolder.slice(MUSIC_ROOT.length + 1).split('/') : [];
const coverFor = (childRel: string) => (manifest[childRel]?.cover ? coverUrl(childRel, token) : null);
const shownLibraries = libraries.filter((l) => fuzzyMatch(query, l));
const shownFolders = folders.filter((f) => fuzzyMatch(query, f));
return (
<div className="flex h-full flex-col overflow-y-auto p-3">
@@ -94,13 +118,39 @@ export const MusicBrowser = () => {
<span className="text-lg font-semibold">Music</span>
</button>
<div className="mb-2 flex items-center gap-1.5 px-1">
<div className="flex h-8 min-w-0 flex-1 items-center gap-1.5 rounded-md border border-border bg-background px-2">
<Search size={13} className="shrink-0 text-muted-foreground" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Filter…"
className="min-w-0 flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
/>
{query && (
<button type="button" onClick={() => setQuery('')} className="shrink-0 text-muted-foreground hover:text-foreground">
<X size={13} />
</button>
)}
</div>
<button
type="button"
onClick={reindex}
disabled={reindexing}
title="Reindex library"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-border text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-50"
>
<RefreshCw size={14} className={reindexing ? 'animate-spin' : ''} />
</button>
</div>
{!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>
<div className="flex flex-col gap-1.5">
{libraries.map((lib) => (
{shownLibraries.map((lib) => (
<button
key={lib}
type="button"
@@ -111,7 +161,9 @@ export const MusicBrowser = () => {
<span className="truncate">{lib}</span>
</button>
))}
{!libraries.length && <span className="px-2 text-base text-muted-foreground">No libraries</span>}
{!shownLibraries.length && (
<span className="px-2 text-base text-muted-foreground">{query ? 'No matches' : 'No libraries'}</span>
)}
</div>
</>
) : (
@@ -125,7 +177,7 @@ export const MusicBrowser = () => {
<span className="truncate">{crumbs.join(' / ')}</span>
</button>
<div className="flex flex-col gap-1.5">
{folders.map((f) => (
{shownFolders.map((f) => (
<button
key={f}
type="button"
@@ -138,7 +190,9 @@ export const MusicBrowser = () => {
<span className="truncate">{f}</span>
</button>
))}
{!folders.length && <span className="px-2 py-2 text-base text-muted-foreground">No subfolders</span>}
{!shownFolders.length && (
<span className="px-2 py-2 text-base text-muted-foreground">{query ? 'No matches' : 'No subfolders'}</span>
)}
</div>
</>
)}
@@ -1,7 +1,7 @@
import { useState, useEffect, useRef } from 'react';
import { useClient } from 'hooks/useClient';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { Play, ChevronLeft } from 'lucide-react';
import { Play, ChevronLeft, Volume2 } from 'lucide-react';
import { MusicHeart } from './MusicHeart';
import { useMusicPlayer } from '../../MusicPlayer';
import type { PlayerTrack } from '../../MusicPlayer';
@@ -10,6 +10,7 @@ import {
MUSIC_CWD_CHANNEL,
TYPE_ORDER,
coverUrl,
fmtDuration,
isAudio,
sortTracks,
toRel,
@@ -232,25 +233,39 @@ export const MusicDetail = () => {
</div>
</div>
<div className="flex flex-col">
{album.map((t, i) => (
<div
key={t.file}
className={`group flex items-center gap-4 rounded px-3 py-2 hover:bg-muted ${
isCurrent(rel, t.file) ? 'text-primary' : 'text-foreground'
}`}
>
<button
type="button"
onClick={() => playCurrent(i)}
className="flex min-w-0 flex-1 items-center gap-4 text-left"
{album.map((t, i) => {
const cur = isCurrent(rel, t.file);
const artist = t.artist ?? t.albumArtist ?? '';
const dur = fmtDuration(t.durationSec);
return (
<div
key={t.file}
className={`group flex items-center gap-3 rounded px-3 py-2 ${
cur ? 'bg-primary/10 text-primary' : 'text-foreground hover:bg-muted'
}`}
>
<span className="w-5 text-right text-sm tabular-nums text-muted-foreground">{i + 1}</span>
<span className="min-w-0 flex-1 truncate text-sm">{t.title ?? t.file}</span>
{t.artist && <span className="hidden truncate text-xs text-muted-foreground sm:block">{t.artist}</span>}
</button>
<MusicHeart kind="track" favKey={trackHomePath(rel, t.file)} size={16} hoverReveal className="shrink-0" />
</div>
))}
<button
type="button"
onClick={() => playCurrent(i)}
className="flex min-w-0 flex-1 items-center gap-3 text-left"
>
<span className="flex w-5 shrink-0 justify-end">
{cur ? (
<Volume2 size={15} className="text-primary" />
) : (
<span className="text-sm tabular-nums text-muted-foreground">{i + 1}</span>
)}
</span>
<span className="min-w-0 flex-1">
<span className={`block truncate text-sm ${cur ? 'font-medium' : ''}`}>{t.title ?? t.file}</span>
{artist && <span className="block truncate text-xs text-muted-foreground">{artist}</span>}
</span>
{dur && <span className="shrink-0 text-xs tabular-nums text-muted-foreground">{dur}</span>}
</button>
<MusicHeart kind="track" favKey={trackHomePath(rel, t.file)} size={16} hoverReveal className="shrink-0" />
</div>
);
})}
</div>
</div>
)}
@@ -8,9 +8,29 @@ export type DirEntry = { name: string; type: 'directory' | 'file'; size: number;
export type LsResult = { entries: DirEntry[] };
export type ManifestAlbum = { v: string; cover: boolean; tracks: number; disco?: boolean };
export type Manifest = { albums: Record<string, ManifestAlbum> };
export type Track = { file: string; title?: string; artist?: string; track?: string };
export type Track = { file: string; title?: string; artist?: string; albumArtist?: string; track?: string; durationSec?: number };
export type AlbumMeta = { path: string; cover?: string; tracks: Track[] };
/** Seconds → "m:ss" (or "h:mm:ss" once past an hour); '' when unknown. */
export const fmtDuration = (sec?: number): string => {
if (!sec || sec <= 0) return '';
const s = Math.round(sec);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const ss = String(s % 60).padStart(2, '0');
return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`;
};
/** Case-insensitive subsequence fuzzy match: every char of `query` appears in order within `text`. */
export const fuzzyMatch = (query: string, text: string): boolean => {
const q = query.trim().toLowerCase();
if (!q) return true;
const t = text.toLowerCase();
let qi = 0;
for (let ti = 0; ti < t.length && qi < q.length; ti++) if (t[ti] === q[qi]!) qi++;
return qi === q.length;
};
/** Parse a track-number tag ("7", "07", "7/14") to a number, or null when absent/unparseable. */
const trackNo = (t: Track): number | null => {
const raw = t.track?.split('/')[0]?.trim();