From 5b17121de83918e2bf14f33a78599786fab3eb38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Mon, 27 Jul 2026 12:52:34 +0000 Subject: [PATCH] music (web): browser filter + reindex; richer album tracklist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/apps/Music/MusicBrowser.tsx | 66 +++++++++++++++++-- .../officerdev/src/apps/Music/MusicDetail.tsx | 53 +++++++++------ .../officerdev/src/apps/Music/shared.ts | 22 ++++++- 3 files changed, 115 insertions(+), 26 deletions(-) diff --git a/src/workspaces/officerdev/src/apps/Music/MusicBrowser.tsx b/src/workspaces/officerdev/src/apps/Music/MusicBrowser.tsx index da506926..31ae8a6f 100644 --- a/src/workspaces/officerdev/src/apps/Music/MusicBrowser.tsx +++ b/src/workspaces/officerdev/src/apps/Music/MusicBrowser.tsx @@ -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(MUSIC_CWD_CHANNEL, null); const [manifest, setManifest] = useState>({}); const [libraries, setLibraries] = useState([]); const [folders, setFolders] = useState([]); + const [query, setQuery] = useState(''); + const [reindexing, setReindexing] = useState(false); useEffect(() => { get('/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('/music/manifest').catch(() => null); + if (m) setManifest(m.albums); + const target = navFolder ?? MUSIC_ROOT; + const r = await get(`/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 (
@@ -94,13 +118,39 @@ export const MusicBrowser = () => { Music +
+
+ + 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 && ( + + )} +
+ +
+ {!navFolder ? ( <>
Libraries
- {libraries.map((lib) => ( + {shownLibraries.map((lib) => ( ))} - {!libraries.length && No libraries} + {!shownLibraries.length && ( + {query ? 'No matches' : 'No libraries'} + )}
) : ( @@ -125,7 +177,7 @@ export const MusicBrowser = () => { {crumbs.join(' / ')}
- {folders.map((f) => ( + {shownFolders.map((f) => ( ))} - {!folders.length && No subfolders} + {!shownFolders.length && ( + {query ? 'No matches' : 'No subfolders'} + )}
)} diff --git a/src/workspaces/officerdev/src/apps/Music/MusicDetail.tsx b/src/workspaces/officerdev/src/apps/Music/MusicDetail.tsx index 1a760950..9796f692 100644 --- a/src/workspaces/officerdev/src/apps/Music/MusicDetail.tsx +++ b/src/workspaces/officerdev/src/apps/Music/MusicDetail.tsx @@ -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 = () => {
- {album.map((t, i) => ( -
- - -
- ))} + + +
+ ); + })} )} diff --git a/src/workspaces/officerdev/src/apps/Music/shared.ts b/src/workspaces/officerdev/src/apps/Music/shared.ts index e6349110..1c23b688 100644 --- a/src/workspaces/officerdev/src/apps/Music/shared.ts +++ b/src/workspaces/officerdev/src/apps/Music/shared.ts @@ -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 }; -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();