Files
music/web/MusicDetail.tsx
T
Claude Opus 5 6e07da7a36 music, extracted from the platform into its own repository
Everything the plugin is, moved out of officerdev/platform on 2026-08-15 —
41 files, unchanged from the tree they left.

  manifest.ts   identity, one permission, ffmpeg/ffprobe declared
  api/          the sidecar proxy; the prefix comes from mountPrefix()
  sidecar/      the whole /api/music contract — indexing, streaming, per-user state
  db/           music_favorites, _playlists, _playlist_items, _now_playing
  web/          panels, layout, and the player: engine, bar, lyrics, favourites
  cliamp/       the second playback path, parked — not working, kept deliberately
  widgets/      the dashboard widget, parked — plugins cannot contribute widgets
  assets/       icon.png, the dock tile
  scripts/      the reindex CLI

PLUGIN.md is the design record: what moved, what stayed, what broke, and why.
MUSIC_API.md is the contract the phone and tablet apps speak, and the reason
the sidecar's HTTP shape is not free to change.

── It does not build here, and that is the point ──

The platform resolves `hooks/useClient`, `officerdev`, `officerdb/db` and `@@/*`
through the workspace links in its own node_modules. Measured from this
directory, outside the platform checkout, every one of them fails to resolve —
7 imports in the backend, ~29 in the frontend.

So this repository is the source of truth, not yet a buildable unit. Making it
one means the host API becoming something a plugin can depend on rather than
something it reaches into. That is the next problem, and having the code here
is what makes it unavoidable rather than theoretical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 17:34:51 +00:00

439 lines
18 KiB
TypeScript

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 './MusicHeart';
import { FavoritesView } from './FavoritesView';
import { useMusicPlayer } from './useMusicPlayer';
import type { PlayerTrack } from './useMusicPlayer';
import { LyricsPanel } from './LyricsPanel';
import { MusicMiniBar } from './MusicMiniBar';
import { MusicPlayerHost } from './MusicPlayerHost';
import { useLyricsOpen } from './useLyricsOpen';
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 />
{/* The audio engine, mounted HERE rather than by the shell.
It moved out of DashboardLayout on 2026-08-15 when the player became the plugin's. It renders
nothing while the route is /music — the mini bar above is the transport — so this is purely
"something owns the GaplessEngine while the screen is open".
[phase 2] Leaving /music unmounts it, which stops playback. Making audio outlive the route
needs either a shell slot a plugin can contribute to, or the engine hoisted to module scope;
that decision is deliberately deferred. Nothing breaks in the meantime: player-time's
registrations are optional-chained and the queue lives in global state, so returning to /music
remounts the host and reloads it. */}
<MusicPlayerHost />
</div>
);
if (!lyricsOpen) return libraryView;
return (
<LibraryViewContext.Provider value={libraryView}>
<WorkspaceLayout layout={LYRICS_LAYOUT} onLayoutChange={keepLayout} components={LYRICS_PANELS} noHeader />
</LibraryViewContext.Provider>
);
};