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:
@@ -3,20 +3,12 @@ import { widgetRegistryMetas as weatherMetas } from 'widgets/Weather';
|
||||
import { widgetRegistryMetas as pomodoroMetas } from 'widgets/Pomodoro';
|
||||
import { widgetRegistryMetas as dailyGoalsMetas } from 'widgets/DailyGoals';
|
||||
import { widgetRegistryMetas as quickNotesMetas } from 'widgets/QuickNotes';
|
||||
import { widgetRegistryMetas as musicPlayerMetas } from 'widgets/MusicPlayer';
|
||||
import type { QueryClient } from '@tanstack/react-query';
|
||||
import { globalQueryKey } from 'hooks/useGlobal';
|
||||
import type { WidgetRegistry as WidgetRegistryMap } from './types';
|
||||
import type { WidgetRegistryMeta } from './useWidgetRegistry';
|
||||
|
||||
const widgets = [
|
||||
...clockMetas,
|
||||
...weatherMetas,
|
||||
...pomodoroMetas,
|
||||
...dailyGoalsMetas,
|
||||
...quickNotesMetas,
|
||||
...musicPlayerMetas,
|
||||
];
|
||||
const widgets = [...clockMetas, ...weatherMetas, ...pomodoroMetas, ...dailyGoalsMetas, ...quickNotesMetas];
|
||||
|
||||
const metasToRegistry = (metas: WidgetRegistryMeta[]): WidgetRegistryMap =>
|
||||
Object.fromEntries(metas.map(({ key, ...entry }) => [key, entry]));
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
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 '../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 },
|
||||
];
|
||||
@@ -8,7 +8,6 @@
|
||||
"./Pomodoro": "./Pomodoro/index.tsx",
|
||||
"./DailyGoals": "./DailyGoals/index.tsx",
|
||||
"./QuickNotes": "./QuickNotes/index.tsx",
|
||||
"./Workspaces": "./Workspaces/index.tsx",
|
||||
"./MusicPlayer": "./MusicPlayer/index.tsx"
|
||||
"./Workspaces": "./Workspaces/index.tsx"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user