diff --git a/docs/navigation-audit.md b/docs/navigation-audit.md index a6c2f994..25421553 100644 --- a/docs/navigation-audit.md +++ b/docs/navigation-audit.md @@ -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=`; rows become ``s. +- ~~**Music**~~ — **done.** The library location is `/music?path=` (`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 `` — library + rows, folder rows, album/artist cards, both "up" affordances, the favorites rows, and the dock's now-playing + tile. Track rows stay ` + {/* transport */}
diff --git a/src/workspaces/officerdev/src/apps/Music/FavoritesView.tsx b/src/workspaces/officerdev/src/apps/Music/FavoritesView.tsx index ddc0226e..7b97422a 100644 --- a/src/workspaces/officerdev/src/apps/Music/FavoritesView.tsx +++ b/src/workspaces/officerdev/src/apps/Music/FavoritesView.tsx @@ -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(MUSIC_CWD_CHANNEL, null); const [, setFavOpen] = usePanelChannel(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={} title={segs[segs.length - 1] ?? key} - onClick={() => goToRel(key)} + to={musicPath(key)} + onClick={() => setFavOpen(false)} chevron /> ); @@ -103,7 +100,8 @@ export const FavoritesView = () => { fallback={} 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
) : 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 = ( + <> +
+ {cover && !failed ? ( + setFailed(true)} /> + ) : ( + fallback + )} +
+
+ {title} + {subtitle ? {subtitle} : null} +
+ + ); + const cls = 'flex min-w-0 flex-1 cursor-pointer items-center gap-3 text-left'; return (
- + {to ? ( + + {inner} + + ) : ( + + )} {chevron ? : null}
diff --git a/src/workspaces/officerdev/src/apps/Music/MusicBrowser.tsx b/src/workspaces/officerdev/src/apps/Music/MusicBrowser.tsx index ecfdf683..9eaf0de1 100644 --- a/src/workspaces/officerdev/src/apps/Music/MusicBrowser.tsx +++ b/src/workspaces/officerdev/src/apps/Music/MusicBrowser.tsx @@ -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(MUSIC_CWD_CHANNEL, null); + const cwd = useMusicCwd(); const [favOpen, setFavOpen] = usePanelChannel(MUSIC_FAV_CHANNEL, false); const [resync, setResync] = usePanelChannel(MUSIC_RESYNC_CHANNEL, 0); const [manifest, setManifest] = useState>({}); @@ -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 (
- +
{shownLibraries.map((lib) => ( - + ))} {!shownLibraries.length && ( {query ? 'No matches' : 'No libraries'} @@ -193,20 +188,18 @@ export const MusicBrowser = () => { ) : ( <> - +
{shownFolders.map((f) => ( - + ))} {!shownFolders.length && ( diff --git a/src/workspaces/officerdev/src/apps/Music/MusicDetail.tsx b/src/workspaces/officerdev/src/apps/Music/MusicDetail.tsx index a74d2bea..f93db0c6 100644 --- a/src/workspaces/officerdev/src/apps/Music/MusicDetail.tsx +++ b/src/workspaces/officerdev/src/apps/Music/MusicDetail.tsx @@ -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(MUSIC_CWD_CHANNEL, null); + const cwd = useMusicCwd(); const [favOpen, setFavOpen] = usePanelChannel(MUSIC_FAV_CHANNEL, false); const [resync] = usePanelChannel(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(`/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 }) => (
- + {playable && ( + )} {loading &&

Loading…

} @@ -271,13 +270,7 @@ export const MusicDetail = () => { {!cwd && (
{libraries.map((lib) => ( - 0} - onOpen={() => setCwd(`${MUSIC_ROOT}/${lib}`)} - /> + 0} /> ))}
)} diff --git a/src/workspaces/officerdev/src/apps/Music/shared.ts b/src/workspaces/officerdev/src/apps/Music/shared.ts index 8a761aa0..635dca2b 100644 --- a/src/workspaces/officerdev/src/apps/Music/shared.ts +++ b/src/workspaces/officerdev/src/apps/Music/shared.ts @@ -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=`, 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; +};