Files
music/widgets/index.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

225 lines
8.5 KiB
TypeScript

import type { WidgetRegistryMeta } from 'officerdev';
// The player is this plugin's own now, so these are siblings rather than host API. Parked: nothing
// registers this widget — plugins cannot contribute widgets, and that mechanism is not built.
import type { PlayerTrack } from '../web/useMusicPlayer';
import { useMusicPlayer } from '../web/useMusicPlayer';
import { useState, useEffect } from 'react';
import { Music, ChevronLeft, Play, Folder } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { Widget } from 'widgets/Widget';
// Music Player widget — a BROWSER over the library. Top-level dirs of ~/Music are "libraries" (tabs);
// within a library you drill through folders (Artist → Albums → …) until a folder has tracks (songs).
// Playback is owned by the app-wide player (useMusicPlayer): selecting a track hands it a queue.
const MUSIC_ROOT = 'Music';
type DirEntry = { name: string; type: 'directory' | 'file'; size: number; modifiedAt: number };
type LsResult = { path: string; rootDir?: string; entries: DirEntry[] };
type Track = { file: string; title?: string; artist?: string };
type AlbumMeta = { path: string; cover?: string; tracks: Track[] };
const AUDIO_EXT = new Set(['mp3', 'flac', 'm4a', 'aac', 'ogg', 'opus', 'wav', 'wma', 'mp4']);
const isAudio = (n: string) => {
const d = n.lastIndexOf('.');
return d >= 0 && AUDIO_EXT.has(n.slice(d + 1).toLowerCase());
};
export const MusicPlayer = () => {
const { token, get } = useClient(); // base '/api'
const player = useMusicPlayer();
const [libraries, setLibraries] = useState<string[]>([]);
const [library, setLibrary] = useState<string | null>(null);
const [cwd, setCwd] = useState<string>(MUSIC_ROOT); // home-relative path
const [dirs, setDirs] = useState<string[]>([]);
const [songs, setSongs] = useState<Track[]>([]);
const [loading, setLoading] = useState(false);
// Top-level libraries (once).
useEffect(() => {
get<LsResult>(`/file-browser/ls?path=${encodeURIComponent(MUSIC_ROOT)}`)
.then((r) =>
setLibraries(
r.entries
.filter((e) => e.type === 'directory')
.map((e) => e.name)
.sort(),
),
)
.catch(() => setLibraries([]));
}, []);
// Current folder contents when cwd changes.
useEffect(() => {
if (!library) return;
let cancelled = false;
setLoading(true);
setDirs([]);
setSongs([]);
get<LsResult>(`/file-browser/ls?path=${encodeURIComponent(cwd)}`)
.then(async (r) => {
if (cancelled) return;
setDirs(
r.entries
.filter((e) => e.type === 'directory')
.map((e) => e.name)
.sort(),
);
const audio = r.entries.filter((e) => e.type === 'file' && isAudio(e.name)).map((e) => e.name);
if (audio.length) {
const rel = cwd.slice(MUSIC_ROOT.length + 1); // <library>/<…>
try {
const meta = await get<AlbumMeta>(`/music/meta?path=${encodeURIComponent(rel)}`);
if (!cancelled) setSongs(meta.tracks);
} catch {
if (!cancelled) setSongs(audio.sort().map((f) => ({ file: f })));
}
}
})
.catch(() => {})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [cwd, library]);
const relToMusic = cwd.slice(MUSIC_ROOT.length + 1); // '' at root, else <library>/<…>
const breadcrumb = relToMusic ? relToMusic.split('/') : [];
const coverUrl = (rel: string) =>
`/api/music/cover?path=${encodeURIComponent(rel)}${token ? `&token=${encodeURIComponent(token)}` : ''}`;
const selectLibrary = (lib: string) => {
setLibrary(lib);
setCwd(`${MUSIC_ROOT}/${lib}`);
};
const enter = (name: string) => setCwd(`${cwd}/${name}`);
const goUp = () => {
const parts = cwd.split('/');
if (parts.length <= 2) {
setLibrary(null);
setCwd(MUSIC_ROOT);
return;
}
setCwd(parts.slice(0, -1).join('/'));
};
const play = (i: number) => {
const queue: PlayerTrack[] = songs.map((t) => ({
albumRel: relToMusic,
file: t.file,
title: t.title,
artist: t.artist,
}));
player.playQueue(queue, i);
};
const isCurrent = (file: string) => player.current?.albumRel === relToMusic && player.current?.file === file;
return (
<Widget title="Music Player" className="w-72">
{/* Library tabs */}
<div className="flex gap-1 overflow-x-auto px-3 pb-2">
{libraries.map((lib) => (
<button
key={lib}
type="button"
onClick={() => selectLibrary(lib)}
className={`shrink-0 rounded-full px-2.5 py-1 text-xs ${
library === lib
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:text-foreground'
}`}
>
{lib}
</button>
))}
{!libraries.length && <span className="px-1 text-xs text-muted-foreground">No libraries</span>}
</div>
{!library ? (
<div className="px-3 pb-4 text-center text-sm text-muted-foreground">Pick a library</div>
) : (
<div className="flex flex-col gap-2 px-3 pb-3">
{/* breadcrumb / back */}
<button
type="button"
onClick={goUp}
className="flex items-center gap-1 truncate text-xs text-muted-foreground hover:text-foreground"
>
<ChevronLeft size={14} className="shrink-0" />
<span className="truncate">{breadcrumb.length ? breadcrumb.join(' / ') : library}</span>
</button>
{/* album header (cover + play-all) when the folder has songs */}
{songs.length > 0 && (
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-md bg-muted">
<img
src={coverUrl(relToMusic)}
alt=""
className="h-full w-full object-cover"
onError={(e) => {
(e.currentTarget as HTMLImageElement).style.visibility = 'hidden';
}}
/>
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-foreground">
{breadcrumb[breadcrumb.length - 1] ?? library}
</p>
<p className="truncate text-xs text-muted-foreground">{breadcrumb[breadcrumb.length - 2] ?? ''}</p>
</div>
<button
type="button"
onClick={() => play(0)}
title="Play all"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground hover:opacity-90 active:scale-95"
>
<Play size={16} className="ml-0.5" />
</button>
</div>
)}
{/* folders + songs */}
<div className="flex max-h-72 flex-col overflow-y-auto">
{dirs.map((d) => (
<button
key={d}
type="button"
onClick={() => enter(d)}
className="flex items-center gap-2 rounded px-2 py-1.5 text-left text-sm text-foreground hover:bg-muted"
>
<Folder size={14} className="shrink-0 text-muted-foreground" />
<span className="truncate">{d}</span>
</button>
))}
{songs.map((t, i) => (
<button
key={t.file}
type="button"
onClick={() => play(i)}
className={`flex items-center gap-2 rounded px-2 py-1 text-left text-xs hover:bg-muted ${
isCurrent(t.file) ? 'text-primary' : 'text-muted-foreground'
}`}
>
<span className="w-4 shrink-0 text-right tabular-nums">{i + 1}</span>
<span className="truncate">{t.title ?? t.file}</span>
</button>
))}
{loading && <span className="px-2 py-3 text-center text-sm text-muted-foreground">Loading</span>}
{!loading && !dirs.length && !songs.length && (
<span className="px-2 py-3 text-center text-sm text-muted-foreground">Empty</span>
)}
</div>
</div>
)}
</Widget>
);
};
export const widgetRegistryMetas: WidgetRegistryMeta[] = [
{ key: 'music-player', name: 'Music Player', icon: Music, component: MusicPlayer },
];