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