music becomes a plugin, and the player stays behind
The whole of music moves to plugins/music/: the sidecar (index, indexer,
stream-audio, nightly-reindex), the four Postgres tables and their queries,
the /music workspace panels, MUSIC_API.md and the reindex CLI. The platform
keeps no music routes, no music capability entry, no music screen and no
music schema.
Three things stayed, each on purpose.
cliamp and the widget were out of scope by the owner's decision. The plugin's
sidecar still serves the two cliamp sockets, so it imports cliamp-ws.ts and
pulse-audio.ts from @@/sidecar/music/ — the files stay where they were.
The player did not move, and that was the open judgement call. Deciding it
took one fact: the dashboard widget imports useMusicPlayer and PlayerTrack
from officerdev, and the platform cannot import from a plugin. So the player
STATE stays whatever is decided about the UI around it, and two copies would
mean two audio engines. Given that, the engine and the bar stayed with the
state rather than being split from the thing they drive. Moving them would
also have needed a shell slot rendering a plugin-provided component on every
route — the one escape hatch this system deleted on purpose. MusicPlayerHost
gates on can('music'), which is now the plugin's permission, so the seam
switches itself off with the plugin.
api/music/router.ts stays too: api/cliamp/relay.ts imports getMusicServerWsUrl
from it. The plugin's api/router.ts re-exports that proxy rather than building
a second one — two subscribers to the one-shot music:server port announcement
would work today and 503 on the first reconnect where only one was listening.
Two bugs found on the way, neither visible from reading.
The app-store catalogue still listed music. Availability is derived from
sidecar_installs and a PLUGIN never gets a row there, so `music` would have
been permanently unavailable — which puts /music into deniedRoutes and blanks
the screen on a server where the plugin was installed and healthy. Exactly
the headscale bug documented six lines above it in the same file, and it would
have fired on the first install. Entry removed.
[test] root was "./src", so moving lyrics.test.ts into plugins/ stopped it
running and said nothing — the count fell by nine and the suite still read
green. Root is now the repo. Positional filters cannot fix this: `bun test
plugins` matches under root and finds src/servers/plugins/ instead.
registry.test.ts tested the `personal` mechanism THROUGH the music capability.
Re-anchored on a fixture rather than on another entry, because borrowing a
feature only moves the problem to the next extraction — and three of those
four tests had been passing for the wrong reason since music's api was
commented out on 2026-08-13, when everything started resolving to "refused
because nothing is claimed". The cliamp sockets being claimed by nothing is
now pinned by a test instead of being rediscovered.
music's `personal` paths ride across on readOnlyWrites, the one field a
manifest has. isRequestAllowedAtLevel concatenates the two lists, so a read
grant permits exactly the four paths it permitted yesterday, and no field was
added to the manifest to design a per-user model that is not this work.
bunx tsgo clean. 772 tests, 762 pass, 7 fail — all seven pre-existing and
unrelated (cliamp, pty, and five capability tests that other switched-off
plugins break). Baseline was 757/10; the three that went green are the ones
re-anchored above.
Not yet verified on the live server — that is next.
This commit is contained in:
@@ -0,0 +1,427 @@
|
||||
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';
|
||||
import type { LayoutNode, PanelComponents } from 'officerdev';
|
||||
import { WorkspaceLayout } from 'officerdev';
|
||||
import { MusicHeart } from 'officerdev';
|
||||
import { FavoritesView } from './FavoritesView';
|
||||
import { useMusicPlayer } from 'officerdev';
|
||||
import type { PlayerTrack } from 'officerdev';
|
||||
import { LyricsPanel } from './LyricsPanel';
|
||||
import { MusicMiniBar } from './MusicMiniBar';
|
||||
import { useLyricsOpen } from 'officerdev';
|
||||
import {
|
||||
MUSIC_ROOT,
|
||||
MUSIC_FAV_CHANNEL,
|
||||
MUSIC_RESYNC_CHANNEL,
|
||||
TYPE_ORDER,
|
||||
coverUrl,
|
||||
fmtDuration,
|
||||
isAudio,
|
||||
musicParentPath,
|
||||
musicPath,
|
||||
sortTracks,
|
||||
toRel,
|
||||
trackHomePath,
|
||||
useMusicCwd,
|
||||
type AlbumMeta,
|
||||
type Discography,
|
||||
type LsResult,
|
||||
type Manifest,
|
||||
type ManifestAlbum,
|
||||
type Track,
|
||||
} from './shared';
|
||||
|
||||
// Turning the lyrics on splits THIS panel in two rather than opening a panel of its own: the workspace
|
||||
// system is a layout engine as well as a shell, so a nested WorkspaceLayout with a fixed layout and
|
||||
// components keyed by panel id gets a resizable split with no persistence and no registry entries.
|
||||
const LYRICS_LAYOUT: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'music-detail-split',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'music-detail-list', appType: null }, size: 62 },
|
||||
{ node: { type: 'panel', id: 'music-detail-lyrics', appType: null }, size: 38 },
|
||||
],
|
||||
};
|
||||
|
||||
// The library view is rendered by MusicDetail itself and passed down through context, so toggling the
|
||||
// split moves the same element rather than mounting a second copy — the fetched album, and every request
|
||||
// that produced it, survives the toggle.
|
||||
const LibraryViewContext = createContext<ReactNode>(null);
|
||||
const LibraryViewPanel = () => <>{useContext(LibraryViewContext)}</>;
|
||||
|
||||
const LYRICS_PANELS: PanelComponents = {
|
||||
'music-detail-list': LibraryViewPanel,
|
||||
'music-detail-lyrics': LyricsPanel,
|
||||
};
|
||||
|
||||
const keepLayout = () => {};
|
||||
|
||||
// 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 = useMusicCwd();
|
||||
const [favOpen, setFavOpen] = usePanelChannel<boolean>(MUSIC_FAV_CHANNEL, false);
|
||||
const [resync] = usePanelChannel<number>(MUSIC_RESYNC_CHANNEL, 0);
|
||||
const [lyricsOpen, toggleLyrics] = useLyricsOpen();
|
||||
|
||||
const [manifest, setManifest] = useState<Record<string, ManifestAlbum>>({});
|
||||
const [libraries, setLibraries] = useState<string[]>([]);
|
||||
const [folders, setFolders] = useState<string[]>([]);
|
||||
const [album, setAlbum] = useState<Track[] | null>(null);
|
||||
const [disco, setDisco] = useState<Discography | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// On first mount with no location yet, open the currently-playing album — so a reload/return lands on
|
||||
// the track you were listening to (the player itself restores via the saved now-playing snapshot).
|
||||
// Once only, so it never yanks you back after you navigate away (e.g. up to the library root).
|
||||
const autoNavRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (autoNavRef.current) return;
|
||||
if (cwd) {
|
||||
autoNavRef.current = true;
|
||||
return;
|
||||
}
|
||||
if (player.current) {
|
||||
autoNavRef.current = true;
|
||||
// `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, navigate]);
|
||||
|
||||
// Navigating anywhere (left panel or from within Favorites) closes the Favorites view.
|
||||
useEffect(() => {
|
||||
setFavOpen(false);
|
||||
// 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(),
|
||||
),
|
||||
)
|
||||
.catch(() => setLibraries([]));
|
||||
}, [resync]);
|
||||
|
||||
const rel = toRel(cwd);
|
||||
const childRel = (name: string) => (rel ? `${rel}/${name}` : name);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cwd) {
|
||||
setFolders([]);
|
||||
setAlbum(null);
|
||||
setDisco(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setAlbum(null);
|
||||
setDisco(null);
|
||||
setFolders([]);
|
||||
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(),
|
||||
);
|
||||
const audio = r.entries.filter((e) => e.type === 'file' && isAudio(e.name)).map((e) => e.name);
|
||||
if (audio.length) {
|
||||
try {
|
||||
const meta = await get<AlbumMeta>(`/music/meta?path=${encodeURIComponent(rel)}`);
|
||||
if (!cancelled) setAlbum(sortTracks(meta.tracks));
|
||||
} catch {
|
||||
if (!cancelled) setAlbum(audio.sort().map((f) => ({ file: f })));
|
||||
}
|
||||
} else if (manifest[rel]?.disco) {
|
||||
try {
|
||||
const d = await get<Discography>(`/music/discography?path=${encodeURIComponent(rel)}`);
|
||||
if (!cancelled) setDisco(d);
|
||||
} catch {
|
||||
/* plain grid */
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [cwd, manifest]);
|
||||
|
||||
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,
|
||||
}));
|
||||
player.playQueue(queue, startIndex);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
const playCurrent = (i: number) => {
|
||||
if (!album) return;
|
||||
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;
|
||||
|
||||
// The album's play button is the dock's play button when the dock is already on this album: same
|
||||
// useGlobal state, so it shows pause while it plays and resumes where it stopped. It only starts the
|
||||
// album from the top when something else (or nothing) is loaded.
|
||||
const albumLoaded = !!album && player.current?.albumRel === rel;
|
||||
const albumPlaying = albumLoaded && player.playing;
|
||||
const toggleAlbum = () => (albumLoaded ? player.toggle() : playCurrent(0));
|
||||
|
||||
const crumbs = rel ? rel.split('/') : [];
|
||||
|
||||
// `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">
|
||||
<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">
|
||||
<img
|
||||
src={coverUrl(r, token)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover"
|
||||
onError={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.visibility = 'hidden';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="truncate text-sm font-medium text-foreground">{name}</span>
|
||||
</Link>
|
||||
{playable && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => playAlbum(r, 0)}
|
||||
className="absolute bottom-14 right-4 flex h-10 w-10 translate-y-2 items-center justify-center rounded-full bg-primary text-primary-foreground opacity-0 shadow-lg transition-all group-hover:translate-y-0 group-hover:opacity-100 hover:scale-105"
|
||||
>
|
||||
<Play size={18} className="ml-0.5" />
|
||||
</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"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const content = favOpen ? (
|
||||
<div className="min-h-0 flex-1">
|
||||
<FavoritesView />
|
||||
</div>
|
||||
) : (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-4 md:p-6">
|
||||
{cwd && (
|
||||
<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>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{loading && <p className="text-sm text-muted-foreground">Loading…</p>}
|
||||
|
||||
{/* Home — the library roots carry their own folder art (the manifest indexes them like any other
|
||||
folder), so they get the same cards as everything else rather than a wall of flat tiles. */}
|
||||
{!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} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Album */}
|
||||
{album && (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex items-end gap-5">
|
||||
<div className="h-40 w-40 shrink-0 overflow-hidden rounded-lg bg-muted shadow-lg">
|
||||
<img
|
||||
src={coverUrl(rel, token)}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
onError={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.visibility = 'hidden';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Album</p>
|
||||
<h1 className="truncate text-3xl font-bold text-foreground">{crumbs[crumbs.length - 1]}</h1>
|
||||
<p className="mt-1 truncate text-sm text-muted-foreground">
|
||||
{crumbs[crumbs.length - 2] ?? ''} · {album.length} songs
|
||||
</p>
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleAlbum}
|
||||
title={albumPlaying ? 'Pause' : 'Play'}
|
||||
className="flex h-10 w-10 cursor-pointer items-center justify-center rounded-full bg-primary text-primary-foreground hover:scale-105"
|
||||
>
|
||||
{albumPlaying ? <Pause size={18} /> : <Play size={18} className="ml-0.5" />}
|
||||
</button>
|
||||
<MusicHeart kind="album" favKey={rel} size={24} className="p-1" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleLyrics}
|
||||
title={lyricsOpen ? 'Hide lyrics' : 'Show lyrics'}
|
||||
aria-pressed={lyricsOpen}
|
||||
className={`cursor-pointer p-1 hover:text-foreground ${
|
||||
lyricsOpen ? 'text-primary' : 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
<MicVocal size={22} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
{album.map((t, i) => {
|
||||
const cur = isCurrent(rel, t.file);
|
||||
const artist = t.artist ?? t.albumArtist ?? '';
|
||||
const dur = fmtDuration(t.durationSec);
|
||||
return (
|
||||
<div
|
||||
key={t.file}
|
||||
className={`group flex items-center gap-3 rounded px-3 py-2 ${
|
||||
cur ? 'bg-primary/10 text-primary' : 'text-foreground hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => playCurrent(i)}
|
||||
className="flex min-w-0 flex-1 items-center gap-3 text-left"
|
||||
>
|
||||
<span className="flex w-5 shrink-0 justify-end">
|
||||
{cur ? (
|
||||
<Volume2 size={15} className="text-primary" />
|
||||
) : (
|
||||
<span className="text-sm tabular-nums text-muted-foreground">{i + 1}</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className={`block truncate text-sm ${cur ? 'font-medium' : ''}`}>{t.title ?? t.file}</span>
|
||||
{artist && <span className="block truncate text-xs text-muted-foreground">{artist}</span>}
|
||||
</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"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Artist — discography sections */}
|
||||
{disco && !album && (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold text-foreground">{disco.artist}</h1>
|
||||
<MusicHeart kind="artist" favKey={rel} size={22} className="p-1" />
|
||||
</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>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||
{folders
|
||||
.filter((f) => (disco.albums[f] ?? 'Other') === type)
|
||||
.map((f) => (
|
||||
<Card key={f} r={childRel(f)} name={f} playable={(manifest[childRel(f)]?.tracks ?? 0) > 0} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Grid — a library root, or an artist folder (crumbs>=2) whose albums aren't grouped by a
|
||||
discography. Show an artist header + heart on the latter. */}
|
||||
{!album && !disco && cwd && !loading && (
|
||||
<div className="flex flex-col gap-6">
|
||||
{crumbs.length >= 2 && (
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold text-foreground">{crumbs[crumbs.length - 1]}</h1>
|
||||
<MusicHeart kind="artist" favKey={rel} size={22} className="p-1" />
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||
{folders.map((f) => (
|
||||
<Card key={f} r={childRel(f)} name={f} playable={(manifest[childRel(f)]?.tracks ?? 0) > 0} />
|
||||
))}
|
||||
{!folders.length && <p className="text-sm text-muted-foreground">Empty</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// The scrubber sits at the foot of this panel instead of the app-wide dock, which hides itself on
|
||||
// /music: the album view already carries the transport, so all the dock added here was a second row.
|
||||
const libraryView = (
|
||||
<div className="flex h-full flex-col">
|
||||
{content}
|
||||
<MusicMiniBar />
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!lyricsOpen) return libraryView;
|
||||
|
||||
return (
|
||||
<LibraryViewContext.Provider value={libraryView}>
|
||||
<WorkspaceLayout layout={LYRICS_LAYOUT} onLayoutChange={keepLayout} components={LYRICS_PANELS} noHeader />
|
||||
</LibraryViewContext.Provider>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user