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>
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
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_FAV_CHANNEL,
|
||||
MUSIC_RESYNC_CHANNEL,
|
||||
coverUrl,
|
||||
fuzzyMatch,
|
||||
musicParentPath,
|
||||
musicPath,
|
||||
toRel,
|
||||
useMusicCwd,
|
||||
type LsResult,
|
||||
type Manifest,
|
||||
type ManifestAlbum,
|
||||
} from './shared';
|
||||
|
||||
// A row's leading thumbnail: the folder's indexed cover (its folder.jpg/cover.jpg, server-compressed),
|
||||
// falling back to an icon when it has none or the image fails to load.
|
||||
const RowThumb = ({ src, fallback }: { src: string | null; fallback: ReactNode }) => {
|
||||
const [failed, setFailed] = useState(false);
|
||||
useEffect(() => setFailed(false), [src]);
|
||||
return (
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded bg-muted">
|
||||
{src && !failed ? (
|
||||
<img src={src} alt="" className="h-full w-full object-cover" onError={() => setFailed(true)} />
|
||||
) : (
|
||||
fallback
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Hidden files/folders (dotfiles like .claude, .git) never belong in the library listing.
|
||||
const visibleDirs = (r: LsResult) =>
|
||||
r.entries
|
||||
.filter((e) => e.type === 'directory' && !e.name.startsWith('.'))
|
||||
.map((e) => e.name)
|
||||
.sort();
|
||||
|
||||
// Left panel of the /music workspace — a single-column drill-down LIST navigator (libraries →
|
||||
// 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 = 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>>({});
|
||||
const [libraries, setLibraries] = useState<string[]>([]);
|
||||
const [folders, setFolders] = useState<string[]>([]);
|
||||
const [query, setQuery] = useState('');
|
||||
const [reindexing, setReindexing] = useState(false);
|
||||
|
||||
// Refetch manifest + libraries on mount and whenever a reindex bumps the resync nonce.
|
||||
useEffect(() => {
|
||||
get<Manifest>('/music/manifest')
|
||||
.then((m) => setManifest(m.albums))
|
||||
.catch(() => setManifest({}));
|
||||
get<LsResult>(`/file-browser/ls?path=${encodeURIComponent(MUSIC_ROOT)}`)
|
||||
.then((r) => setLibraries(visibleDirs(r)))
|
||||
.catch(() => setLibraries([]));
|
||||
}, [resync]);
|
||||
|
||||
// The container folder whose children we list = the current folder, or its parent when the current
|
||||
// path is an album leaf (so its siblings stay listed while the right shows the tracklist).
|
||||
const rel = toRel(cwd);
|
||||
const isAlbum = (manifest[rel]?.tracks ?? 0) > 0;
|
||||
const navFolder = !cwd ? null : isAlbum ? cwd.split('/').slice(0, -1).join('/') : cwd;
|
||||
const selected = cwd ? cwd.split('/').pop() : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!navFolder) {
|
||||
setFolders([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
get<LsResult>(`/file-browser/ls?path=${encodeURIComponent(navFolder)}`)
|
||||
.then((r) => {
|
||||
if (!cancelled) setFolders(visibleDirs(r));
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [navFolder, resync]);
|
||||
|
||||
// Start each folder unfiltered.
|
||||
useEffect(() => setQuery(''), [navFolder]);
|
||||
|
||||
// Trigger a server-side library rebuild, then bump the resync nonce so BOTH panels refetch their
|
||||
// manifest / listings / meta (a fresh Date.now() value guarantees the effects re-run).
|
||||
const reindex = async () => {
|
||||
if (reindexing) return;
|
||||
setReindexing(true);
|
||||
try {
|
||||
await post('/music/reindex');
|
||||
setResync(Date.now());
|
||||
} finally {
|
||||
setReindexing(false);
|
||||
}
|
||||
};
|
||||
|
||||
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));
|
||||
const shownFolders = folders.filter((f) => fuzzyMatch(query, f));
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-y-auto p-3">
|
||||
<div className="flex items-center gap-1 pb-2">
|
||||
{/* 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>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFavOpen(!favOpen)}
|
||||
title="Favorites"
|
||||
className="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-md hover:bg-muted"
|
||||
>
|
||||
<Heart
|
||||
size={18}
|
||||
className={favOpen ? 'fill-red-500 text-red-500' : 'text-muted-foreground hover:text-foreground'}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-2 flex items-center gap-1.5 px-1">
|
||||
<div className="flex h-8 min-w-0 flex-1 items-center gap-1.5 rounded-md border border-border bg-background px-2">
|
||||
<Search size={13} className="shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Filter…"
|
||||
className="min-w-0 flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuery('')}
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={reindex}
|
||||
disabled={reindexing}
|
||||
title="Reindex library"
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-border text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw size={14} className={reindexing ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!navFolder ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2 px-2 pb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<Library size={13} /> Libraries
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{shownLibraries.map((lib) => (
|
||||
<Link
|
||||
key={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>
|
||||
</Link>
|
||||
))}
|
||||
{!shownLibraries.length && (
|
||||
<span className="px-2 text-base text-muted-foreground">{query ? 'No matches' : 'No libraries'}</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
</Link>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{shownFolders.map((f) => (
|
||||
<Link
|
||||
key={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'
|
||||
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<RowThumb
|
||||
src={coverFor(toRel(`${navFolder}/${f}`))}
|
||||
fallback={<Folder size={18} className="text-muted-foreground" />}
|
||||
/>
|
||||
<span className="truncate">{f}</span>
|
||||
</Link>
|
||||
))}
|
||||
{!shownFolders.length && (
|
||||
<span className="px-2 py-2 text-base text-muted-foreground">
|
||||
{query ? 'No matches' : 'No subfolders'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user