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 { 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, Search, X, RefreshCw } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
MUSIC_ROOT,
|
MUSIC_ROOT,
|
||||||
MUSIC_CWD_CHANNEL,
|
MUSIC_CWD_CHANNEL,
|
||||||
coverUrl,
|
coverUrl,
|
||||||
|
fuzzyMatch,
|
||||||
toRel,
|
toRel,
|
||||||
type LsResult,
|
type LsResult,
|
||||||
type Manifest,
|
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'
|
// 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, token } = useClient();
|
const { get, post, 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[]>([]);
|
||||||
const [folders, setFolders] = useState<string[]>([]);
|
const [folders, setFolders] = useState<string[]>([]);
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [reindexing, setReindexing] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
get<Manifest>('/music/manifest')
|
get<Manifest>('/music/manifest')
|
||||||
@@ -74,6 +77,25 @@ export const MusicBrowser = () => {
|
|||||||
};
|
};
|
||||||
}, [navFolder]);
|
}, [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 = () => {
|
const up = () => {
|
||||||
if (!navFolder) return;
|
if (!navFolder) return;
|
||||||
const parts = navFolder.split('/');
|
const parts = navFolder.split('/');
|
||||||
@@ -82,6 +104,8 @@ 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);
|
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 (
|
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">
|
||||||
@@ -94,13 +118,39 @@ export const MusicBrowser = () => {
|
|||||||
<span className="text-lg font-semibold">Music</span>
|
<span className="text-lg font-semibold">Music</span>
|
||||||
</button>
|
</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 ? (
|
{!navFolder ? (
|
||||||
<>
|
<>
|
||||||
<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>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
{libraries.map((lib) => (
|
{shownLibraries.map((lib) => (
|
||||||
<button
|
<button
|
||||||
key={lib}
|
key={lib}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -111,7 +161,9 @@ export const MusicBrowser = () => {
|
|||||||
<span className="truncate">{lib}</span>
|
<span className="truncate">{lib}</span>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -125,7 +177,7 @@ export const MusicBrowser = () => {
|
|||||||
<span className="truncate">{crumbs.join(' / ')}</span>
|
<span className="truncate">{crumbs.join(' / ')}</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
{folders.map((f) => (
|
{shownFolders.map((f) => (
|
||||||
<button
|
<button
|
||||||
key={f}
|
key={f}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -138,7 +190,9 @@ export const MusicBrowser = () => {
|
|||||||
<span className="truncate">{f}</span>
|
<span className="truncate">{f}</span>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||||
import { Play, ChevronLeft } from 'lucide-react';
|
import { Play, ChevronLeft, Volume2 } from 'lucide-react';
|
||||||
import { MusicHeart } from './MusicHeart';
|
import { MusicHeart } from './MusicHeart';
|
||||||
import { useMusicPlayer } from '../../MusicPlayer';
|
import { useMusicPlayer } from '../../MusicPlayer';
|
||||||
import type { PlayerTrack } from '../../MusicPlayer';
|
import type { PlayerTrack } from '../../MusicPlayer';
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
MUSIC_CWD_CHANNEL,
|
MUSIC_CWD_CHANNEL,
|
||||||
TYPE_ORDER,
|
TYPE_ORDER,
|
||||||
coverUrl,
|
coverUrl,
|
||||||
|
fmtDuration,
|
||||||
isAudio,
|
isAudio,
|
||||||
sortTracks,
|
sortTracks,
|
||||||
toRel,
|
toRel,
|
||||||
@@ -232,25 +233,39 @@ export const MusicDetail = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
{album.map((t, i) => (
|
{album.map((t, i) => {
|
||||||
|
const cur = isCurrent(rel, t.file);
|
||||||
|
const artist = t.artist ?? t.albumArtist ?? '';
|
||||||
|
const dur = fmtDuration(t.durationSec);
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={t.file}
|
key={t.file}
|
||||||
className={`group flex items-center gap-4 rounded px-3 py-2 hover:bg-muted ${
|
className={`group flex items-center gap-3 rounded px-3 py-2 ${
|
||||||
isCurrent(rel, t.file) ? 'text-primary' : 'text-foreground'
|
cur ? 'bg-primary/10 text-primary' : 'text-foreground hover:bg-muted'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => playCurrent(i)}
|
onClick={() => playCurrent(i)}
|
||||||
className="flex min-w-0 flex-1 items-center gap-4 text-left"
|
className="flex min-w-0 flex-1 items-center gap-3 text-left"
|
||||||
>
|
>
|
||||||
<span className="w-5 text-right text-sm tabular-nums text-muted-foreground">{i + 1}</span>
|
<span className="flex w-5 shrink-0 justify-end">
|
||||||
<span className="min-w-0 flex-1 truncate text-sm">{t.title ?? t.file}</span>
|
{cur ? (
|
||||||
{t.artist && <span className="hidden truncate text-xs text-muted-foreground sm:block">{t.artist}</span>}
|
<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>
|
</button>
|
||||||
<MusicHeart kind="track" favKey={trackHomePath(rel, t.file)} size={16} hoverReveal className="shrink-0" />
|
<MusicHeart kind="track" favKey={trackHomePath(rel, t.file)} size={16} hoverReveal className="shrink-0" />
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -8,9 +8,29 @@ export type DirEntry = { name: string; type: 'directory' | 'file'; size: number;
|
|||||||
export type LsResult = { entries: DirEntry[] };
|
export type LsResult = { entries: DirEntry[] };
|
||||||
export type ManifestAlbum = { v: string; cover: boolean; tracks: number; disco?: boolean };
|
export type ManifestAlbum = { v: string; cover: boolean; tracks: number; disco?: boolean };
|
||||||
export type Manifest = { albums: Record<string, ManifestAlbum> };
|
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[] };
|
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. */
|
/** Parse a track-number tag ("7", "07", "7/14") to a number, or null when absent/unparseable. */
|
||||||
const trackNo = (t: Track): number | null => {
|
const trackNo = (t: Track): number | null => {
|
||||||
const raw = t.track?.split('/')[0]?.trim();
|
const raw = t.track?.split('/')[0]?.trim();
|
||||||
|
|||||||
Reference in New Issue
Block a user