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:
@@ -87,9 +87,15 @@ are good building blocks. The **Workspace/Panel framework** contains **zero** ro
|
||||
**Music** (M-music) and **Soulseek** (M-slsk) are whole-workspace channel apps — pulled out below because each
|
||||
is **one design decision** that cascades across many files:
|
||||
|
||||
- **Music** — `Music/{MusicBrowser:182,209 · MusicDetail:138,174,234 · FavoritesView:85,107}` hold the entire
|
||||
library location in `usePanelChannel('music:cwd')`; every drill-in is `setCwd(path)`. No URL, no deep-link to
|
||||
an album. → back `music:cwd` with `/music?path=<rel>`; rows become `<Link>`s.
|
||||
- ~~**Music**~~ — **done.** The library location is `/music?path=<rel>` (`rel` relative to the `Music` root);
|
||||
`music:cwd` is deleted. Each panel calls `useMusicCwd()` and reads the param itself, so `MusicBrowser`,
|
||||
`MusicDetail` and `FavoritesView` no longer tell each other anything. 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 `<button>`s: they play, which is a mutation. Two things that were channel-shaped and
|
||||
stayed channels: `music:favorites` (a view toggle over this one panel) and `music:resync` (a refresh signal).
|
||||
A query param rather than `/music/*` because the location is only one of the things this screen holds — the
|
||||
lyrics split and the favorites view are the others — and because a splat has to be a route's last segment.
|
||||
**Needs runtime test.**
|
||||
- ~~**Soulseek**~~ — **mostly done.** The section is `/soulseek/:section` (nav entries are `NavLink`s, the
|
||||
view panel reads the same URL), the peer is `?user=<name>`, and the past-search was already `?search=<id>`.
|
||||
The `soulseek:section` and `soulseek:user` channels are deleted; `soulseek:refresh` stays, which is a
|
||||
@@ -114,7 +120,11 @@ is **one design decision** that cascades across many files:
|
||||
- **Jobs step/iteration** (`Jobs/JobDetail.tsx:234,255`) — intra-detail sub-selection; only if we want `/jobs/:id/:stepKey` deep links.
|
||||
- **Preview slug** (`Preview/PreviewApp.tsx:23`) — Preview usually derives its target from the surrounding dashboard/project `cwd`; the manual picker is a fallback. Probably leave URL-less.
|
||||
- **FileBrowser widget** (`FileBrowserWidget/.../BrowseTab.tsx:36`) — compact sidebar widget; keeping its browse cursor local is defensible.
|
||||
- **Music favorites view** (`music:fav`) & **SystemMonitor scope** — view toggles over tiny fixed sets; channel-state is arguably fine.
|
||||
- **Music favorites view** — the channel is `music:favorites`, not `music:fav`. **Decided: it stays a channel.**
|
||||
It is a view of the detail panel rather than a location, it survives no reload worth surviving, and the one
|
||||
case where its being off-URL shows is handled explicitly: going Home or opening a favorite closes it by hand,
|
||||
because navigating to where you already are fires no route change. (**SystemMonitor scope** was listed here
|
||||
as the same kind of thing and was not — it is M10, and it is in the URL.)
|
||||
- **"New Chat" button** (`ChatHistory/SessionList.tsx:68`) — a create-action that also `navigate('/chat/new')`; could be `<Link to="/chat/new">` if the state-set moves into the route. Its `chat:selected-session` write is part of the H4 cleanup.
|
||||
|
||||
---
|
||||
@@ -130,7 +140,6 @@ This is the primary surface to convert to URL-driven selection.
|
||||
| `chat:active-cwd` | chat working dir | query param on `/chat` | `ChatDetailPanel.tsx:102`, `SessionList.tsx:14` |
|
||||
| `SELECTED_DASHBOARD_KEY` (`useGlobal`) | selected dashboard | `/dashboards/:id` | `DashboardListApp.tsx:36`, `DashboardPreview.tsx:287` (H2) |
|
||||
| `SELECTED_PROJECT` (`useGlobal`) | selected project | `/projects/:id` | `ProjectListApp.tsx:45`, `ProjectPreview.tsx:386` (H3) |
|
||||
| `music:cwd` | library location (album/artist/folder) | `/music?path=` | `MusicBrowser/MusicDetail/FavoritesView` |
|
||||
| local `useState` selections | search / room / conversation / peer / open file / preview slug | respective nested routes | `SearchView`, `SoulseekRooms`, `SoulseekChat`, `SoulseekUsers`, `CodeEditor/useEditorState`, `PreviewProvider` |
|
||||
|
||||
**Excluded — event-bus / refresh signals, NOT selection:** `files:refresh-signal`,
|
||||
@@ -163,7 +172,7 @@ publishers means changing the chat panel, which is another agent's, so it is wri
|
||||
| 10 | `Jobs/JobDetail.tsx:709` | back button | `/jobs` | YES | use `BackButton`/`<Link>` |
|
||||
| 11 | `ChatHistory/SessionList.tsx:70` | "New Chat" | `/chat/new` | ~ | borderline (LOW) |
|
||||
| 12 | `components/Combobox.tsx:54` | nav combobox option | `option.href` | YES | M7 |
|
||||
| 13 | `MusicPlayer/MusicPlayerHost.tsx:251` | open current album (setCwd+nav) | `/music` | ~ | has side-effect |
|
||||
| ~~13~~ | `MusicPlayer/MusicPlayerHost.tsx:251` | open current album (setCwd+nav) | `/music` | ~ | **Done** — the side-effect was the `setCwd`; with the location in the URL the tile is a plain `<Link>` to `musicPath(albumRel)`. |
|
||||
| 14 | `FileBrowserWidget/useFileBrowserWidget.ts:53` | open folder in Files | `/files?view=…` | ~ | query nav |
|
||||
| 15 | `FileBrowserApp/useFileBrowserApp.ts:401` | create dashboard from folder | `/dashboards/new?…` | ~ | action-nav |
|
||||
| 16 | `FileBrowserApp/useFileBrowserApp.ts:427` | create dashboard from files | `/dashboards/new?…` | ~ | action-nav |
|
||||
@@ -209,7 +218,7 @@ publishers means changing the chat panel, which is another agent's, so it is wri
|
||||
- [ ] **M7** Combobox: render `href` options as `<Link>` (`Combobox.tsx:53`).
|
||||
|
||||
### Phase 3 — Whole-workspace routing decisions (needs a design call first)
|
||||
- [ ] **Music** — decide `/music?path=` (or nested); back `music:cwd` with the URL; rows → `<Link>` (M-music).
|
||||
- [x] **Music** — `/music?path=<rel>`; `music:cwd` deleted; every drill-in (including the dock's now-playing tile, navigate-site 13) is a `<Link>`. `music:favorites` and `music:resync` stay — a view toggle and a refresh signal. **Needs runtime test.**
|
||||
- [x] **Soulseek** — `/soulseek/:section` with the peer in `?user=` and the search already in `?search=`; the two selection channels are deleted. Rooms and conversations are still `useState`. **Needs runtime test.**
|
||||
- [x] **M10** SystemMonitor scope → `/system-monitor/:scope`; `monitor:scope` channel deleted. **Needs runtime test.**
|
||||
- [ ] **M9** Plans → `/plans/:name` (`Plans/index.tsx:36`).
|
||||
@@ -218,7 +227,7 @@ publishers means changing the chat panel, which is another agent's, so it is wri
|
||||
- [ ] Dock + Header + mobile sheet → react-router `<NavLink>` for active state; drop hand-rolled `isActive` (`Dock.tsx`, `Header.tsx`).
|
||||
- [ ] "New Chat" → `<Link to="/chat/new">` (`SessionList.tsx:68`) once H4's channel cleanup lands.
|
||||
- [ ] Jobs back button + any `navigate('/jobs')` → shared `BackButton` (`JobDetail.tsx:709`).
|
||||
- [ ] Decide/skip: Jobs step deep-link, Preview slug, FileBrowser widget, Music-favorites view toggle. (Browser tabs: **done** — see the LOW section. Monitor scope: **done** as M10, it was not a view toggle.)
|
||||
- [ ] Decide/skip: Jobs step deep-link, Preview slug, FileBrowser widget. (Browser tabs: **done** — see the LOW section. Monitor scope: **done** as M10, it was not a view toggle. Music favorites: **decided** — stays a channel, reasoning in the LOW section.)
|
||||
|
||||
### Cross-cutting for the refactor itself
|
||||
- [ ] Standardise a URL-as-source-of-truth pattern for panel selection (replace the `usePanelChannel`/`useGlobal`
|
||||
|
||||
@@ -621,7 +621,9 @@ All the same bug: an app guessing "am I being closed?" from an unmount, or payin
|
||||
|
||||
### 5.8 Navigation — finish the refactor
|
||||
|
||||
- [ ] **Move the seven channel-driven apps' selection into the URL.** Split today: 11 URL-driven
|
||||
- [x] **Move the seven channel-driven apps' selection into the URL.** _Six of the seven are done; the
|
||||
seventh (Chat detail) is chat-owned and deliberately untouched — see the foot of this item._
|
||||
Split when this was written: 11 URL-driven
|
||||
(everything post-refactor), 7 channel-driven, 3 on bare `useState`. The channel-driven set: Music
|
||||
(`music:cwd`), Soulseek sections, SystemMonitor (`monitor:scope`), Chat detail
|
||||
(`chat:selected-session`), Email, Browser, and all five Settings pages (one fix point,
|
||||
@@ -678,8 +680,31 @@ All the same bug: an app guessing "am I being closed?" from an unmount, or payin
|
||||
Focus and Close buttons were nested inside the row `<button>` (invalid HTML, held together by two
|
||||
`stopPropagation` calls — they are siblings of the anchor now), and "Set up in Integrations" was a
|
||||
raw `<a href>` that reloaded the whole SPA.
|
||||
Remaining here: Music. **Chat detail (`chat:selected-session`) is chat-owned —
|
||||
leave it; it is written up in `COMMS/`.**
|
||||
**Music is done** — the last of the seven that is mine, and the largest: the location touched four
|
||||
files across two packages. It is `/music?path=<rel>`, `rel` relative to the `Music` root, and
|
||||
`music:cwd` is deleted. A query param rather than the nested route the audit left open, for two
|
||||
reasons: 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, which is the same
|
||||
constraint `/chat` hit when it spelled its group `/chat/g/*`. `useMusicCwd()` returns the location
|
||||
in the home-relative `Music/…` vocabulary the panels already spoke, so reading the URL cost each
|
||||
panel one line and no rewrite.
|
||||
What fell out of it is the point of the whole section. `MusicBrowser` published, `MusicDetail` and
|
||||
`FavoritesView` subscribed, and `MusicPlayerHost` — mounted *outside* `<Routes>` — wrote the channel
|
||||
and then called `navigate('/music')` to make the write visible, which is navigate-site 13 and the
|
||||
only "navigate with a side-effect" in the audit's table. With the location in the URL that pair
|
||||
collapses to one `<Link>`, and the host stops importing the library's channel at all. Every
|
||||
drill-in is a link now: library rows, folder rows, album and artist cards, both "up" affordances,
|
||||
the favorites rows, and the dock's now-playing tile. Track rows stay `<button>`s — they play, which
|
||||
is a mutation, not a location. `MusicDetail`'s once-only auto-nav to the playing album navigates
|
||||
with `replace`, on the same reasoning as Email's arrow keys: landing on `/music` and being moved to
|
||||
the playing album is one arrival, so Back should leave the screen rather than undo a jump the user
|
||||
never asked for. Two channels survived deliberately: `music:resync` (a refresh signal, never
|
||||
selection) and `music:favorites` (a view of one panel). The favorites one needs a note — going Home
|
||||
or opening a favorite has to close it *by hand*, because navigating to where you already are fires
|
||||
no route change and the effect that clears it keys on the location.
|
||||
Remaining here: **nothing that is mine.** Chat detail (`chat:selected-session`) is chat-owned —
|
||||
leave it; it is written up in `COMMS/`. Soulseek's rooms and conversations are still `useState`,
|
||||
noted in the audit and not scheduled.
|
||||
|
||||
### 5.9 The context has grown an app-config section — _(found 2026-08-07)_
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user