the music widget moves into the plugin, parked like cliamp

src/workspaces/widgets/MusicPlayer/ → plugins/music/widgets/, its export
dropped from the widgets package, and its registration removed from
WidgetRegistry. Plugins cannot contribute widgets and that mechanism is not
being invented now, so it is parked next to cliamp rather than left in a
workspace package the platform ships.

`../Widget` became `widgets/Widget` — the sibling import turned into that
package's declared export, which it already had.

This removes the last non-plugin consumer of officerdev/src/MusicPlayer, and
that matters more than the move. The argument for keeping the player in the
platform was that the widget imported useMusicPlayer and PlayerTrack from
officerdev, and the platform cannot import from a plugin — so the player STATE
had to stay whatever was decided about the UI, and the engine stayed with the
state.

That constraint is now gone. The complete remaining platform dependency on the
player is one line: DashboardLayout.tsx:66, `<MusicPlayerHost />`.

So the seam is no longer "the widget pins it". It is purely the global overlay
question, which is a platform gap about plugins owning a render slot outside
<Routes> — the same shape as plugins owning a websocket. Recorded rather than
acted on; the decision is the owner's and the previous reasoning for it no
longer holds.

bunx tsgo clean. 797 tests, 787 pass, same 7 pre-existing failures.
This commit is contained in:
2026-08-15 14:33:44 +00:00
parent 9af52fd754
commit f1bd75853d
3 changed files with 29 additions and 17 deletions
+221
View File
@@ -0,0 +1,221 @@
import type { WidgetRegistryMeta, PlayerTrack } from 'officerdev';
import { useMusicPlayer } from 'officerdev';
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 },
];