import { useState, useEffect, type ReactNode } from 'react'; import { Link } from 'react-router'; import { useClient } from 'hooks/useClient'; import { usePanelChannel } from 'hooks/usePanelChannel'; import { Library, Music2, ChevronLeft, Folder, Search, X, RefreshCw, Heart } from 'lucide-react'; import { MUSIC_ROOT, MUSIC_FAV_CHANNEL, MUSIC_RESYNC_CHANNEL, coverUrl, fuzzyMatch, musicParentPath, musicPath, toRel, useMusicCwd, type LsResult, type Manifest, type ManifestAlbum, } 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 (
{src && !failed ? ( setFailed(true)} /> ) : ( fallback )}
); }; // 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 → // artists → albums as list items; never a grid). Every row is a link to `/music?path=…`; MusicDetail // (right panel) reads the same param and renders the rich detail (covers/grids/tracklist). export const MusicBrowser = () => { const { get, post, token } = useClient(); const cwd = useMusicCwd(); const [favOpen, setFavOpen] = usePanelChannel(MUSIC_FAV_CHANNEL, false); const [resync, setResync] = usePanelChannel(MUSIC_RESYNC_CHANNEL, 0); const [manifest, setManifest] = useState>({}); const [libraries, setLibraries] = useState([]); const [folders, setFolders] = useState([]); const [query, setQuery] = useState(''); const [reindexing, setReindexing] = useState(false); // Refetch manifest + libraries on mount and whenever a reindex bumps the resync nonce. useEffect(() => { get('/music/manifest') .then((m) => setManifest(m.albums)) .catch(() => setManifest({})); get(`/file-browser/ls?path=${encodeURIComponent(MUSIC_ROOT)}`) .then((r) => setLibraries(visibleDirs(r))) .catch(() => setLibraries([])); }, [resync]); // The container folder whose children we list = the current folder, or its parent when the current // path is an album leaf (so its siblings stay listed while the right shows the tracklist). const rel = toRel(cwd); const isAlbum = (manifest[rel]?.tracks ?? 0) > 0; const navFolder = !cwd ? null : isAlbum ? cwd.split('/').slice(0, -1).join('/') : cwd; const selected = cwd ? cwd.split('/').pop() : null; useEffect(() => { if (!navFolder) { setFolders([]); return; } let cancelled = false; get(`/file-browser/ls?path=${encodeURIComponent(navFolder)}`) .then((r) => { if (!cancelled) setFolders(visibleDirs(r)); }) .catch(() => {}); return () => { cancelled = true; }; }, [navFolder, resync]); // Start each folder unfiltered. useEffect(() => setQuery(''), [navFolder]); // Trigger a server-side library rebuild, then bump the resync nonce so BOTH panels refetch their // manifest / listings / meta (a fresh Date.now() value guarantees the effects re-run). const reindex = async () => { if (reindexing) return; setReindexing(true); try { await post('/music/reindex'); setResync(Date.now()); } finally { setReindexing(false); } }; 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 (
{/* Favorites is a view of this panel, not a location, so it stays a channel — but going home has to close it explicitly: the route doesn't change when you are already at the root. */} setFavOpen(false)} className="flex flex-1 cursor-pointer items-center gap-2 px-2 text-left text-foreground" > 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
{shownLibraries.map((lib) => ( } /> {lib} ))} {!shownLibraries.length && ( {query ? 'No matches' : 'No libraries'} )}
) : ( <> {crumbs.join(' / ')}
{shownFolders.map((f) => ( } /> {f} ))} {!shownFolders.length && ( {query ? 'No matches' : 'No subfolders'} )}
)}
); };