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>
439 lines
18 KiB
TypeScript
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>
|
|
);
|
|
};
|