officerdev/src/MusicPlayer/ → plugins/music/web/. Engine, state, bar, favourites, lyrics toggle and the library vocabulary — ten files. The barrel stops exporting a player it no longer has, and DashboardLayout stops rendering one. The reasoning that kept it was removed rather than refuted. It stayed because the dashboard widget imported useMusicPlayer from officerdev and the platform cannot import from a plugin, so the state had to stay whatever was decided about the UI. The owner moved the widget into the plugin in the previous commit, and the constraint went with it: the whole remaining dependency became one line, DashboardLayout.tsx:66. MusicPlayerHost is mounted inside the MusicDetail panel. That reads odd until you notice it already returned null on /music — the mini bar is the transport there, and the host existed purely to own the GaplessEngine. In the panel it does exactly that, and the bar code stays intact for whenever there is a slot. [phase 2] Leaving /music unmounts the host and playback stops. Deferred on the owner's call; the bar was "navigating away must not break the application", and that holds: seekPlayer is optional-chained so a call with no host registered is a no-op, registerPlayerSeek clears only its own registration, the host's cleanup destroys the engine and nulls its ref, and the queue is global state so returning to /music remounts and reloads. Solving it properly needs either a shell slot a plugin can contribute to — which reopens "there is no way to export a component" — or the engine hoisted to module scope, which keeps the rule and loses only the off-route controls. Also: the parked widget now imports the player as a sibling rather than through officerdev, and shared.ts stopped being a re-export shim now that the real file is in the plugin. Verified: tsgo clean, 797 tests / 787 pass / same 7. Server restarts, mounts /example /music /offscale, / and /music both 200, and the player is in the built bundle (music.volume, music:lyrics, now-playing?device=web all present — GaplessEngine is a class name and the production build is minified, so grepping for it proves nothing). Not verified by me: what it looks like in a browser. That needs your eyes.
225 lines
8.5 KiB
TypeScript
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 },
|
|
];
|