put the music library location in the url

/music?path=<rel> replaces the music:cwd channel. Each panel reads the param
itself through useMusicCwd(), so MusicBrowser, MusicDetail and FavoritesView
no longer tell each other where they are, and every drill-in is a <Link>:
library rows, folder rows, album/artist cards, both "up" affordances, the
favorites rows, and the dock's now-playing tile. Track rows stay buttons —
they play, which is a mutation.

A query param rather than a nested route because the location is only one of
the things this screen holds (the lyrics split and the favorites view are the
others), and a splat has to be a route's last segment.

MusicPlayerHost is mounted outside <Routes> and used to write the channel and
then navigate('/music') to make the write visible — the audit's only
navigate-with-a-side-effect. That collapses to one <Link>.

music:resync (a refresh signal) and music:favorites (a view of one panel) stay
channels, deliberately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 12:08:04 +00:00
co-authored by Claude Opus 5
parent 374140d3a6
commit aef8619c6c
8 changed files with 164 additions and 122 deletions
@@ -4,7 +4,9 @@ import { useDashboardState } from 'state/useDashboardState';
import { defaultLayout } from './defaultLayout';
// /music uses the Workspace/Panel system (like /chat): two vertical panels — the library browser
// (music-browser) and the content/detail (music-detail) — coordinating via the 'music:cwd' channel.
// (music-browser) and the content/detail (music-detail). They do not coordinate with each other; both
// read `?path=` off the URL. No route pair and no guard: the bare /music is the library root, a real
// state, and an unknown path gets an empty listing rather than a rewritten address.
export const MusicScreen = () => {
const workspace = useDashboardState<LayoutNode>('screens/music', defaultLayout);
@@ -1,19 +1,10 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router';
import { Link, useLocation, useNavigate } from 'react-router';
import { useClient } from 'hooks/useClient';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { Play, Pause, SkipBack, SkipForward, X, Volume2, VolumeX, Loader2, MicVocal } from 'lucide-react';
import { SeekBar } from '../apps/FileViewer/renderers/SeekBar';
import { MusicHeart } from '../apps/Music/MusicHeart';
import {
MUSIC_ROOT,
MUSIC_CWD_CHANNEL,
fmtClock,
sortTracks,
trackHomePath,
type AlbumMeta,
type NowPlaying,
} from '../apps/Music/shared';
import { fmtClock, musicPath, sortTracks, trackHomePath, type AlbumMeta, type NowPlaying } from '../apps/Music/shared';
import { useMusicPlayer, type PlayerTrack } from './useMusicPlayer';
import { GaplessEngine, type EngineTrack } from './gapless-engine';
import { publishPlayerTime, registerPlayerSeek } from './player-time';
@@ -34,7 +25,6 @@ export const MusicPlayerHost = () => {
const { token, get, put, delete: del } = useClient();
const navigate = useNavigate();
const { pathname } = useLocation();
const [, setCwd] = usePanelChannel<string | null>(MUSIC_CWD_CHANNEL, null);
const { current, index, queue, playing, toggle, next, prev, setPlaying, syncIndex, close, loadQueue } =
useMusicPlayer();
@@ -256,13 +246,6 @@ export const MusicPlayerHost = () => {
close();
};
// Clicking the track info jumps the /music library to the playing album (and navigates there).
const openCurrentAlbum = () => {
if (!current) return;
setCwd(`${MUSIC_ROOT}/${current.albumRel}`);
navigate('/music');
};
// The dock's microphone opens the lyrics panel inside the /music workspace, so it navigates there
// rather than growing a sheet of its own — the dock keeps its height on every screen.
const showLyrics = () => {
@@ -281,10 +264,9 @@ export const MusicPlayerHost = () => {
// fit and the nav dock naturally sits above it, no overlap hacks needed.
return (
<div className="flex shrink-0 items-center gap-3 border-t border-border bg-card/95 px-4 py-2 backdrop-blur">
{/* cover + info — click to open this album in /music */}
<button
type="button"
onClick={openCurrentAlbum}
{/* cover + info — a real link to the playing album, so it cmd-clicks like anything else */}
<Link
to={musicPath(current.albumRel)}
title="Show in library"
className="flex min-w-0 cursor-pointer items-center gap-3 rounded text-left transition-opacity hover:opacity-80"
>
@@ -302,7 +284,7 @@ export const MusicPlayerHost = () => {
<p className="truncate text-sm font-medium text-foreground">{current.title ?? current.file}</p>
<p className="truncate text-xs text-muted-foreground">{subtitle}</p>
</div>
</button>
</Link>
{/* transport */}
<div className="flex shrink-0 items-center gap-1">
@@ -1,4 +1,5 @@
import { useState, type ReactNode } from 'react';
import { Link, useNavigate } from 'react-router';
import { useClient } from 'hooks/useClient';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { Heart, User, Disc3, Music, ChevronRight, X } from 'lucide-react';
@@ -6,10 +7,9 @@ import { useMusicPlayer, type PlayerTrack } from '../../MusicPlayer';
import { MusicHeart } from './MusicHeart';
import { useMusicFavorites } from './useMusicFavorites';
import {
MUSIC_ROOT,
MUSIC_CWD_CHANNEL,
MUSIC_FAV_CHANNEL,
coverUrl,
musicPath,
parseAlbumName,
sortTracks,
toRel,
@@ -19,19 +19,15 @@ import {
// The user's favorited artists / albums / tracks, grouped — shown in the right panel. Keys follow the
// favorites convention: album/artist are music-relative ("Albums/…"), tracks are home paths
// ("Music/…/file"). Clicking an album/artist navigates the library there; clicking a track plays it.
// ("Music/…/file"). An album/artist row is a link into the library; a track row plays, so it stays a
// button — it mutates rather than navigates, even though it also moves the library to the album.
export const FavoritesView = () => {
const { get, token } = useClient();
const navigate = useNavigate();
const player = useMusicPlayer();
const { favorites } = useMusicFavorites();
const [, setCwd] = usePanelChannel<string | null>(MUSIC_CWD_CHANNEL, null);
const [, setFavOpen] = usePanelChannel<boolean>(MUSIC_FAV_CHANNEL, false);
const goToRel = (rel: string) => {
setCwd(`${MUSIC_ROOT}/${rel}`);
setFavOpen(false);
};
const playTrack = async (homePath: string) => {
const cut = homePath.lastIndexOf('/');
const albumHome = homePath.slice(0, cut);
@@ -44,7 +40,7 @@ export const FavoritesView = () => {
} catch {
player.playQueue([{ albumRel, file }], 0);
}
setCwd(albumHome);
navigate(musicPath(albumRel));
setFavOpen(false);
};
@@ -82,7 +78,8 @@ export const FavoritesView = () => {
cover={coverUrl(key, token)}
fallback={<User size={18} className="text-muted-foreground" />}
title={segs[segs.length - 1] ?? key}
onClick={() => goToRel(key)}
to={musicPath(key)}
onClick={() => setFavOpen(false)}
chevron
/>
);
@@ -103,7 +100,8 @@ export const FavoritesView = () => {
fallback={<Disc3 size={18} className="text-muted-foreground" />}
title={title}
subtitle={[artist, year].filter(Boolean).join(' · ')}
onClick={() => goToRel(key)}
to={musicPath(key)}
onClick={() => setFavOpen(false)}
chevron
/>
);
@@ -146,6 +144,8 @@ const Section = ({ title, count, children }: { title: string; count: number; chi
</div>
) : null;
// `to` makes the row an anchor (an album or artist, which is a place); without it the row is a button
// (a track, which plays). The heart stays a sibling either way — it must not be inside either one.
const FavRow = ({
kind,
favKey,
@@ -153,6 +153,7 @@ const FavRow = ({
fallback,
title,
subtitle,
to,
onClick,
chevron,
}: {
@@ -162,25 +163,38 @@ const FavRow = ({
fallback: ReactNode;
title: string;
subtitle?: string;
to?: string;
onClick: () => void;
chevron?: boolean;
}) => {
const [failed, setFailed] = useState(false);
const inner = (
<>
<div className="flex h-11 w-11 shrink-0 items-center justify-center overflow-hidden rounded bg-muted">
{cover && !failed ? (
<img src={cover} alt="" className="h-full w-full object-cover" onError={() => setFailed(true)} />
) : (
fallback
)}
</div>
<div className="min-w-0 flex-1">
<span className="block truncate text-sm text-foreground">{title}</span>
{subtitle ? <span className="block truncate text-xs text-muted-foreground">{subtitle}</span> : null}
</div>
</>
);
const cls = 'flex min-w-0 flex-1 cursor-pointer items-center gap-3 text-left';
return (
<div className="group flex items-center gap-3 rounded-md px-2 py-1.5 hover:bg-muted">
<button type="button" onClick={onClick} className="flex min-w-0 flex-1 cursor-pointer items-center gap-3 text-left">
<div className="flex h-11 w-11 shrink-0 items-center justify-center overflow-hidden rounded bg-muted">
{cover && !failed ? (
<img src={cover} alt="" className="h-full w-full object-cover" onError={() => setFailed(true)} />
) : (
fallback
)}
</div>
<div className="min-w-0 flex-1">
<span className="block truncate text-sm text-foreground">{title}</span>
{subtitle ? <span className="block truncate text-xs text-muted-foreground">{subtitle}</span> : null}
</div>
</button>
{to ? (
<Link to={to} onClick={onClick} className={cls}>
{inner}
</Link>
) : (
<button type="button" onClick={onClick} className={cls}>
{inner}
</button>
)}
<MusicHeart kind={kind} favKey={favKey} size={16} className="shrink-0" />
{chevron ? <ChevronRight size={16} className="shrink-0 text-muted-foreground" /> : null}
</div>
@@ -1,15 +1,18 @@
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_CWD_CHANNEL,
MUSIC_FAV_CHANNEL,
MUSIC_RESYNC_CHANNEL,
coverUrl,
fuzzyMatch,
musicParentPath,
musicPath,
toRel,
useMusicCwd,
type LsResult,
type Manifest,
type ManifestAlbum,
@@ -39,11 +42,11 @@ const visibleDirs = (r: LsResult) =>
.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'
// channel; MusicDetail (right panel) renders the rich detail (covers/grids/tracklist).
// 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, setCwd] = usePanelChannel<string | null>(MUSIC_CWD_CHANNEL, null);
const cwd = useMusicCwd();
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>>({});
@@ -101,12 +104,6 @@ export const MusicBrowser = () => {
}
};
const up = () => {
if (!navFolder) return;
const parts = navFolder.split('/');
setCwd(parts.length <= 2 ? null : parts.slice(0, -1).join('/'));
};
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));
@@ -115,17 +112,16 @@ export const MusicBrowser = () => {
return (
<div className="flex h-full flex-col overflow-y-auto p-3">
<div className="flex items-center gap-1 pb-2">
<button
type="button"
onClick={() => {
setCwd(null);
setFavOpen(false);
}}
{/* 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. */}
<Link
to="/music"
onClick={() => setFavOpen(false)}
className="flex flex-1 cursor-pointer items-center gap-2 px-2 text-left text-foreground"
>
<Music2 size={20} className="text-primary" />
<span className="text-lg font-semibold">Music</span>
</button>
</Link>
<button
type="button"
onClick={() => setFavOpen(!favOpen)}
@@ -176,15 +172,14 @@ export const MusicBrowser = () => {
</div>
<div className="flex flex-col gap-1.5">
{shownLibraries.map((lib) => (
<button
<Link
key={lib}
type="button"
onClick={() => setCwd(`${MUSIC_ROOT}/${lib}`)}
to={musicPath(lib)}
className="flex items-center gap-3 truncate rounded-md px-2 py-2 text-left text-base text-muted-foreground hover:bg-muted/60 hover:text-foreground"
>
<RowThumb src={coverFor(lib)} fallback={<Library size={18} className="text-muted-foreground" />} />
<span className="truncate">{lib}</span>
</button>
</Link>
))}
{!shownLibraries.length && (
<span className="px-2 text-base text-muted-foreground">{query ? 'No matches' : 'No libraries'}</span>
@@ -193,20 +188,18 @@ export const MusicBrowser = () => {
</>
) : (
<>
<button
type="button"
onClick={up}
<Link
to={musicParentPath(toRel(navFolder))}
className="mb-1 flex items-center gap-1 truncate px-2 py-1 text-left text-xs text-muted-foreground hover:text-foreground"
>
<ChevronLeft size={13} className="shrink-0" />
<span className="truncate">{crumbs.join(' / ')}</span>
</button>
</Link>
<div className="flex flex-col gap-1.5">
{shownFolders.map((f) => (
<button
<Link
key={f}
type="button"
onClick={() => setCwd(`${navFolder}/${f}`)}
to={musicPath(toRel(`${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'
@@ -218,7 +211,7 @@ export const MusicBrowser = () => {
fallback={<Folder size={18} className="text-muted-foreground" />}
/>
<span className="truncate">{f}</span>
</button>
</Link>
))}
{!shownFolders.length && (
<span className="px-2 py-2 text-base text-muted-foreground">
@@ -1,5 +1,6 @@
import type { ReactNode } from 'react';
import { createContext, useContext, useState, useEffect, useRef } from 'react';
import { Link, useNavigate } from 'react-router';
import { useClient } from 'hooks/useClient';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { Play, Pause, ChevronLeft, MicVocal, Volume2 } from 'lucide-react';
@@ -14,16 +15,18 @@ import { MusicMiniBar } from '../../MusicPlayer/MusicMiniBar';
import { useLyricsOpen } from '../../MusicPlayer/useLyricsOpen';
import {
MUSIC_ROOT,
MUSIC_CWD_CHANNEL,
MUSIC_FAV_CHANNEL,
MUSIC_RESYNC_CHANNEL,
TYPE_ORDER,
coverUrl,
fmtDuration,
isAudio,
musicParentPath,
musicPath,
sortTracks,
toRel,
trackHomePath,
useMusicCwd,
type AlbumMeta,
type Discography,
type LsResult,
@@ -58,13 +61,14 @@ const LYRICS_PANELS: PanelComponents = {
const keepLayout = () => {};
// Right panel of the /music workspace — renders the content of the current 'music:cwd': an album
// (tracklist), an artist (album cards grouped by discography type), or a folder grid. Drilling in
// updates the shared channel; playback goes through the app-wide player.
// Right panel of the /music workspace — renders the content of `/music?path=…`: an album (tracklist),
// an artist (album cards grouped by discography type), or a folder grid. Drilling in is a link, so it
// changes the address; playback goes through the app-wide player.
export const MusicDetail = () => {
const { token, get } = useClient();
const navigate = useNavigate();
const player = useMusicPlayer();
const [cwd, setCwd] = usePanelChannel<string | null>(MUSIC_CWD_CHANNEL, null);
const cwd = useMusicCwd();
const [favOpen, setFavOpen] = usePanelChannel<boolean>(MUSIC_FAV_CHANNEL, false);
const [resync] = usePanelChannel<number>(MUSIC_RESYNC_CHANNEL, 0);
const [lyricsOpen, toggleLyrics] = useLyricsOpen();
@@ -88,9 +92,11 @@ export const MusicDetail = () => {
}
if (player.current) {
autoNavRef.current = true;
setCwd(`${MUSIC_ROOT}/${player.current.albumRel}`);
// `replace`: landing on /music and being moved to the playing album is one arrival, not two, so
// Back should leave the screen rather than undo a jump the user never asked for.
navigate(musicPath(player.current.albumRel), { replace: true });
}
}, [player.current, cwd, setCwd]);
}, [player.current, cwd, navigate]);
// Navigating anywhere (left panel or from within Favorites) closes the Favorites view.
useEffect(() => {
@@ -168,13 +174,6 @@ export const MusicDetail = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cwd, manifest]);
const enter = (name: string) => setCwd(`${cwd}/${name}`);
const goUp = () => {
if (!cwd) return;
const parts = cwd.split('/');
setCwd(parts.length <= 2 ? null : parts.slice(0, -1).join('/'));
};
const playAlbum = async (albumRel: string, startIndex = 0) => {
try {
const meta = await get<AlbumMeta>(`/music/meta?path=${encodeURIComponent(albumRel)}`);
@@ -206,11 +205,12 @@ export const MusicDetail = () => {
const crumbs = rel ? rel.split('/') : [];
const Card = ({ r, name, playable, onOpen }: { r: string; name: string; playable: boolean; onOpen?: () => void }) => (
// `r` is already the child's rel, so it is both the cover key and the link target — a library root and
// a nested album need no different treatment. Play and heart are siblings of the anchor, never inside it.
const Card = ({ r, name, playable }: { r: string; name: string; playable: boolean }) => (
<div className="group relative">
<button
type="button"
onClick={onOpen ?? (() => enter(name))}
<Link
to={musicPath(r)}
className="flex w-full flex-col gap-2 rounded-lg bg-card/60 p-3 text-left transition-colors hover:bg-card"
>
<div className="aspect-square w-full overflow-hidden rounded-md bg-muted">
@@ -225,7 +225,7 @@ export const MusicDetail = () => {
/>
</div>
<span className="truncate text-sm font-medium text-foreground">{name}</span>
</button>
</Link>
{playable && (
<button
type="button"
@@ -254,14 +254,13 @@ export const MusicDetail = () => {
) : (
<div className="min-h-0 flex-1 overflow-y-auto p-4 md:p-6">
{cwd && (
<button
type="button"
onClick={goUp}
<Link
to={musicParentPath(rel)}
className="mb-4 flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
>
<ChevronLeft size={16} />
<span className="truncate">{crumbs.length ? crumbs.join(' / ') : cwd.split('/')[1]}</span>
</button>
</Link>
)}
{loading && <p className="text-sm text-muted-foreground">Loading</p>}
@@ -271,13 +270,7 @@ export const MusicDetail = () => {
{!cwd && (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
{libraries.map((lib) => (
<Card
key={lib}
r={lib}
name={lib}
playable={(manifest[lib]?.tracks ?? 0) > 0}
onOpen={() => setCwd(`${MUSIC_ROOT}/${lib}`)}
/>
<Card key={lib} r={lib} name={lib} playable={(manifest[lib]?.tracks ?? 0) > 0} />
))}
</div>
)}
@@ -1,8 +1,9 @@
// Shared types/helpers for the /music workspace panels (MusicBrowser + MusicDetail), which coordinate
// via the 'music:cwd' panel channel and play through the app-wide useMusicPlayer.
// via the `?path=` search param and play through the app-wide useMusicPlayer.
import { useSearchParams } from 'react-router';
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.
@@ -126,3 +127,26 @@ export const coverUrl = (rel: string, token: string | null) =>
/** Path (home-relative) → rel (relative to the Music root). */
export const toRel = (cwd: string | null) => (cwd ? cwd.slice(MUSIC_ROOT.length + 1) : '');
// Where you are in the library is `/music?path=<rel>`, not a `music:cwd` channel. A query param rather
// than `/music/*` because the location is one of several things this screen holds (the lyrics split and
// the favorites view are the others), and because a splat would have to be the last segment of the
// route — the same reason /chat spells its group that way. `rel === ''` is the library root, which is
// the bare /music and a real state, so there is no redirect guard.
export const MUSIC_PATH_PARAM = 'path';
/** Link target for a library location. `rel` is relative to the Music root; '' is the root itself. */
export const musicPath = (rel: string) => (rel ? `/music?${MUSIC_PATH_PARAM}=${encodeURIComponent(rel)}` : '/music');
/** Link target for the parent of `rel` — '' (the root) is its own parent, which is where "up" stops. */
export const musicParentPath = (rel: string) => musicPath(rel.split('/').slice(0, -1).join('/'));
/**
* The open library folder as a home-relative path ("Music/…"), or null at the root — the vocabulary the
* panels already speak, so reading the URL costs them nothing. Each panel calls this itself; they never
* tell each other where they are.
*/
export const useMusicCwd = (): string | null => {
const rel = useSearchParams()[0].get(MUSIC_PATH_PARAM)?.trim() ?? '';
return rel ? `${MUSIC_ROOT}/${rel}` : null;
};