web /music: reindex now refreshes BOTH panels in place
The MusicBrowser and MusicDetail panels each kept their OWN local `manifest` (and folder-listing) state, fetched in their own effects. The reindex (↻) button lives in MusicBrowser and only refreshed its own copy — MusicDetail (the right panel showing the tracklist/grid) never heard about it, so newly-indexed content only appeared after navigating (which re-ran its effects). Add a shared `music:resync` panel channel: when a reindex completes, MusicBrowser bumps it to a fresh nonce, and both panels re-run their manifest/libraries/folder fetches. In MusicDetail the fresh manifest object identity also re-triggers the [cwd, manifest] listing effect, so the open album's meta/tracklist and any folder grid refresh in place — no navigation required. Drops the browser's now-redundant hand-refresh of its own state. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
||||
MUSIC_ROOT,
|
||||
MUSIC_CWD_CHANNEL,
|
||||
MUSIC_FAV_CHANNEL,
|
||||
MUSIC_RESYNC_CHANNEL,
|
||||
coverUrl,
|
||||
fuzzyMatch,
|
||||
toRel,
|
||||
@@ -32,7 +33,10 @@ const RowThumb = ({ src, fallback }: { src: string | null; fallback: ReactNode }
|
||||
|
||||
// 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();
|
||||
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). Publishes the selected path to the 'music:cwd'
|
||||
@@ -41,12 +45,14 @@ export const MusicBrowser = () => {
|
||||
const { get, post, token } = useClient();
|
||||
const [cwd, setCwd] = usePanelChannel<string | null>(MUSIC_CWD_CHANNEL, null);
|
||||
const [favOpen, setFavOpen] = usePanelChannel<boolean>(MUSIC_FAV_CHANNEL, false);
|
||||
const [resync, setResync] = usePanelChannel<number>(MUSIC_RESYNC_CHANNEL, 0);
|
||||
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);
|
||||
|
||||
// Refetch manifest + libraries on mount and whenever a reindex bumps the resync nonce.
|
||||
useEffect(() => {
|
||||
get<Manifest>('/music/manifest')
|
||||
.then((m) => setManifest(m.albums))
|
||||
@@ -54,7 +60,7 @@ export const MusicBrowser = () => {
|
||||
get<LsResult>(`/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).
|
||||
@@ -77,22 +83,19 @@ export const MusicBrowser = () => {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [navFolder]);
|
||||
}, [navFolder, resync]);
|
||||
|
||||
// Start each folder unfiltered.
|
||||
useEffect(() => setQuery(''), [navFolder]);
|
||||
|
||||
// Trigger a server-side library rebuild, then refresh the manifest + current listing.
|
||||
// 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');
|
||||
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));
|
||||
setResync(Date.now());
|
||||
} finally {
|
||||
setReindexing(false);
|
||||
}
|
||||
@@ -129,7 +132,10 @@ export const MusicBrowser = () => {
|
||||
title="Favorites"
|
||||
className="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-md hover:bg-muted"
|
||||
>
|
||||
<Heart size={18} className={favOpen ? 'fill-red-500 text-red-500' : 'text-muted-foreground hover:text-foreground'} />
|
||||
<Heart
|
||||
size={18}
|
||||
className={favOpen ? 'fill-red-500 text-red-500' : 'text-muted-foreground hover:text-foreground'}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -143,7 +149,11 @@ export const MusicBrowser = () => {
|
||||
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">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuery('')}
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
)}
|
||||
@@ -198,15 +208,22 @@ export const MusicBrowser = () => {
|
||||
type="button"
|
||||
onClick={() => setCwd(`${navFolder}/${f}`)}
|
||||
className={`flex items-center gap-3 truncate rounded-md px-2 py-2 text-left text-base ${
|
||||
selected === f ? 'bg-muted text-foreground' : 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'
|
||||
selected === f
|
||||
? 'bg-muted text-foreground'
|
||||
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<RowThumb src={coverFor(toRel(`${navFolder}/${f}`))} fallback={<Folder size={18} className="text-muted-foreground" />} />
|
||||
<RowThumb
|
||||
src={coverFor(toRel(`${navFolder}/${f}`))}
|
||||
fallback={<Folder size={18} className="text-muted-foreground" />}
|
||||
/>
|
||||
<span className="truncate">{f}</span>
|
||||
</button>
|
||||
))}
|
||||
{!shownFolders.length && (
|
||||
<span className="px-2 py-2 text-base text-muted-foreground">{query ? 'No matches' : 'No subfolders'}</span>
|
||||
<span className="px-2 py-2 text-base text-muted-foreground">
|
||||
{query ? 'No matches' : 'No subfolders'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
MUSIC_ROOT,
|
||||
MUSIC_CWD_CHANNEL,
|
||||
MUSIC_FAV_CHANNEL,
|
||||
MUSIC_RESYNC_CHANNEL,
|
||||
TYPE_ORDER,
|
||||
coverUrl,
|
||||
fmtDuration,
|
||||
@@ -33,6 +34,7 @@ export const MusicDetail = () => {
|
||||
const player = useMusicPlayer();
|
||||
const [cwd, setCwd] = usePanelChannel<string | null>(MUSIC_CWD_CHANNEL, null);
|
||||
const [favOpen, setFavOpen] = usePanelChannel<boolean>(MUSIC_FAV_CHANNEL, false);
|
||||
const [resync] = usePanelChannel<number>(MUSIC_RESYNC_CHANNEL, 0);
|
||||
|
||||
const [manifest, setManifest] = useState<Record<string, ManifestAlbum>>({});
|
||||
const [libraries, setLibraries] = useState<string[]>([]);
|
||||
@@ -63,14 +65,24 @@ export const MusicDetail = () => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [cwd]);
|
||||
|
||||
// Refetch manifest + libraries on mount and whenever a reindex bumps the resync nonce. A fresh manifest
|
||||
// object identity also re-runs the [cwd, manifest] listing effect below, refreshing the folder grid /
|
||||
// album meta for whatever is currently open — so the right panel updates in place, no nav required.
|
||||
useEffect(() => {
|
||||
get<Manifest>('/music/manifest')
|
||||
.then((m) => setManifest(m.albums))
|
||||
.catch(() => setManifest({}));
|
||||
get<LsResult>(`/file-browser/ls?path=${encodeURIComponent(MUSIC_ROOT)}`)
|
||||
.then((r) => setLibraries(r.entries.filter((e) => e.type === 'directory' && !e.name.startsWith('.')).map((e) => e.name).sort()))
|
||||
.then((r) =>
|
||||
setLibraries(
|
||||
r.entries
|
||||
.filter((e) => e.type === 'directory' && !e.name.startsWith('.'))
|
||||
.map((e) => e.name)
|
||||
.sort(),
|
||||
),
|
||||
)
|
||||
.catch(() => setLibraries([]));
|
||||
}, []);
|
||||
}, [resync]);
|
||||
|
||||
const rel = toRel(cwd);
|
||||
const childRel = (name: string) => (rel ? `${rel}/${name}` : name);
|
||||
@@ -90,7 +102,12 @@ export const MusicDetail = () => {
|
||||
get<LsResult>(`/file-browser/ls?path=${encodeURIComponent(cwd)}`)
|
||||
.then(async (r) => {
|
||||
if (cancelled) return;
|
||||
setFolders(r.entries.filter((e) => e.type === 'directory' && !e.name.startsWith('.')).map((e) => e.name).sort());
|
||||
setFolders(
|
||||
r.entries
|
||||
.filter((e) => e.type === 'directory' && !e.name.startsWith('.'))
|
||||
.map((e) => e.name)
|
||||
.sort(),
|
||||
);
|
||||
const audio = r.entries.filter((e) => e.type === 'file' && isAudio(e.name)).map((e) => e.name);
|
||||
if (audio.length) {
|
||||
try {
|
||||
@@ -128,7 +145,12 @@ export const MusicDetail = () => {
|
||||
const playAlbum = async (albumRel: string, startIndex = 0) => {
|
||||
try {
|
||||
const meta = await get<AlbumMeta>(`/music/meta?path=${encodeURIComponent(albumRel)}`);
|
||||
const queue: PlayerTrack[] = sortTracks(meta.tracks).map((t) => ({ albumRel, file: t.file, title: t.title, artist: t.artist }));
|
||||
const queue: PlayerTrack[] = sortTracks(meta.tracks).map((t) => ({
|
||||
albumRel,
|
||||
file: t.file,
|
||||
title: t.title,
|
||||
artist: t.artist,
|
||||
}));
|
||||
player.playQueue(queue, startIndex);
|
||||
} catch {
|
||||
/* ignore */
|
||||
@@ -139,7 +161,8 @@ export const MusicDetail = () => {
|
||||
const queue: PlayerTrack[] = album.map((t) => ({ albumRel: rel, file: t.file, title: t.title, artist: t.artist }));
|
||||
player.playQueue(queue, i);
|
||||
};
|
||||
const isCurrent = (albumRel: string, file: string) => player.current?.albumRel === albumRel && player.current?.file === file;
|
||||
const isCurrent = (albumRel: string, file: string) =>
|
||||
player.current?.albumRel === albumRel && player.current?.file === file;
|
||||
|
||||
const crumbs = rel ? rel.split('/') : [];
|
||||
|
||||
@@ -173,7 +196,13 @@ export const MusicDetail = () => {
|
||||
</button>
|
||||
)}
|
||||
{playable && (
|
||||
<MusicHeart kind="album" favKey={r} size={18} hoverReveal className="absolute right-2 top-2 rounded-full bg-black/40 p-1.5 text-white" />
|
||||
<MusicHeart
|
||||
kind="album"
|
||||
favKey={r}
|
||||
size={18}
|
||||
hoverReveal
|
||||
className="absolute right-2 top-2 rounded-full bg-black/40 p-1.5 text-white"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -273,7 +302,13 @@ export const MusicDetail = () => {
|
||||
</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" />
|
||||
<MusicHeart
|
||||
kind="track"
|
||||
favKey={trackHomePath(rel, t.file)}
|
||||
size={16}
|
||||
hoverReveal
|
||||
className="shrink-0"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -290,7 +325,9 @@ export const MusicDetail = () => {
|
||||
</div>
|
||||
{TYPE_ORDER.filter((type) => folders.some((f) => (disco.albums[f] ?? 'Other') === type)).map((type) => (
|
||||
<section key={type}>
|
||||
<h2 className="mb-2 text-lg font-semibold text-foreground">{type === 'Studio' ? 'Studio Albums' : type}</h2>
|
||||
<h2 className="mb-2 text-lg font-semibold text-foreground">
|
||||
{type === 'Studio' ? 'Studio Albums' : type}
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||
{folders
|
||||
.filter((f) => (disco.albums[f] ?? 'Other') === type)
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
export const MUSIC_ROOT = 'Music';
|
||||
export const MUSIC_CWD_CHANNEL = 'music:cwd';
|
||||
export const MUSIC_FAV_CHANNEL = 'music:favorites';
|
||||
// Bumped (to a fresh nonce) when a library reindex finishes, so BOTH panels re-run their manifest /
|
||||
// listing / meta fetches — otherwise only the panel that triggered the reindex refreshes.
|
||||
export const MUSIC_RESYNC_CHANNEL = 'music:resync';
|
||||
|
||||
// Album folders are named "[year] Album Name" → display as "Album Name" + year.
|
||||
const ALBUM_NAME_RE = /^\[(\d{4})\]\s*(.+)$/;
|
||||
@@ -16,7 +19,14 @@ 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; albumArtist?: string; track?: string; durationSec?: number };
|
||||
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. */
|
||||
|
||||
Reference in New Issue
Block a user