music: Music Player widget + app-wide player dock

The widget browses the /api/music/* library (search albums → tracklist) and hands
a queue to an app-wide player. The player (useMusicPlayer, useGlobal-backed) and
its site-wide bottom dock (MusicPlayerHost) live in the persistent DashboardLayout,
so playback survives route changes. Dock has cover/title/artist, drag-scrubbing
(SeekBar), volume (persisted), and prev/play/next/close. The nav Dock slides up by
MUSIC_DOCK_HEIGHT while the music dock is present.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 10:29:14 +00:00
co-authored by Claude Opus 4.8
parent 8a1e8cd79e
commit 3308e4e24d
9 changed files with 385 additions and 4 deletions
@@ -0,0 +1,158 @@
import type { WidgetRegistryMeta, PlayerTrack } from 'officerdev';
import { useMusicPlayer } from 'officerdev';
import { useState, useEffect, useMemo } from 'react';
import { Music, Search, ChevronLeft, Play } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { Widget } from '../Widget';
// Music Player widget — a BROWSER over the /api/music/* library. Playback is owned by the app-wide
// player (useMusicPlayer / MusicPlayerHost): selecting a track hands a queue to the global player, which
// keeps playing across route changes and shows the site-wide dock.
const MUSIC_API = '/api/music';
type ManifestAlbum = { v: string; cover: boolean; tracks: number; disco?: boolean };
type Manifest = { version: number; generatedAt: number; albums: Record<string, ManifestAlbum> };
type Track = { file: string; title?: string; artist?: string };
type AlbumMeta = { path: string; cover?: string; tracks: Track[] };
export const MusicPlayer = () => {
const { token, get } = useClient(MUSIC_API);
const player = useMusicPlayer();
const [albums, setAlbums] = useState<string[]>([]);
const [query, setQuery] = useState('');
const [albumRel, setAlbumRel] = useState<string | null>(null);
const [tracks, setTracks] = useState<Track[]>([]);
useEffect(() => {
get<Manifest>('/manifest')
.then((m) =>
setAlbums(
Object.entries(m.albums)
.filter(([, a]) => a.tracks > 0)
.map(([rel]) => rel)
.sort(),
),
)
.catch(() => setAlbums([]));
}, []);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return (q ? albums.filter((a) => a.toLowerCase().includes(q)) : albums).slice(0, 80);
}, [albums, query]);
const coverUrl = (rel: string) =>
`${MUSIC_API}/cover?path=${encodeURIComponent(rel)}${token ? `&token=${encodeURIComponent(token)}` : ''}`;
const openAlbum = async (rel: string) => {
try {
const meta = await get<AlbumMeta>(`/meta?path=${encodeURIComponent(rel)}`);
setAlbumRel(rel);
setTracks(meta.tracks);
} catch {
/* ignore */
}
};
const play = (rel: string, ts: Track[], i: number) => {
const queue: PlayerTrack[] = ts.map((t) => ({ albumRel: rel, file: t.file, title: t.title, artist: t.artist }));
player.playQueue(queue, i);
};
const albumTitle = (rel: string) => rel.split('/').pop() ?? rel;
const artistName = (rel: string) => rel.split('/').slice(-2, -1)[0] ?? '';
const isCurrent = (rel: string, file: string) => player.current?.albumRel === rel && player.current?.file === file;
return (
<Widget title="Music Player" className="w-72">
{!albumRel ? (
// ── Browse albums ──
<div className="flex flex-col gap-2 px-3 pb-3">
<div className="flex items-center gap-2 rounded-md bg-muted px-2">
<Search size={14} className="text-muted-foreground" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search albums…"
className="w-full bg-transparent py-1.5 text-sm outline-none placeholder:text-muted-foreground"
/>
</div>
<div className="flex max-h-72 flex-col overflow-y-auto">
{filtered.map((rel) => (
<button
key={rel}
type="button"
onClick={() => openAlbum(rel)}
className="flex max-w-full flex-col items-start rounded px-2 py-1.5 text-left hover:bg-muted"
>
<span className="max-w-full truncate text-sm text-foreground">{albumTitle(rel)}</span>
<span className="max-w-full truncate text-xs text-muted-foreground">{artistName(rel)}</span>
</button>
))}
{!filtered.length && (
<span className="px-2 py-4 text-center text-sm text-muted-foreground">No albums reindex first?</span>
)}
</div>
</div>
) : (
// ── Album track list ──
<div className="flex flex-col gap-2 px-3 pb-3">
<button
type="button"
onClick={() => setAlbumRel(null)}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
<ChevronLeft size={14} /> Library
</button>
<div className="flex items-center gap-3">
<div className="flex h-14 w-14 shrink-0 items-center justify-center overflow-hidden rounded-md bg-muted">
<img
src={coverUrl(albumRel)}
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">{albumTitle(albumRel)}</p>
<p className="truncate text-xs text-muted-foreground">{artistName(albumRel)}</p>
</div>
<button
type="button"
onClick={() => play(albumRel, tracks, 0)}
className="flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground hover:opacity-90 active:scale-95"
title="Play album"
>
<Play size={16} className="ml-0.5" />
</button>
</div>
<div className="flex max-h-56 flex-col overflow-y-auto">
{tracks.map((t, i) => (
<button
key={t.file}
type="button"
onClick={() => play(albumRel, tracks, i)}
className={`flex items-center gap-2 rounded px-2 py-1 text-left text-xs hover:bg-muted ${
isCurrent(albumRel, t.file) ? 'text-primary' : 'text-muted-foreground'
}`}
>
<span className="w-4 text-right tabular-nums">{i + 1}</span>
<span className="truncate">{t.title ?? t.file}</span>
</button>
))}
</div>
</div>
)}
</Widget>
);
};
export const widgetRegistryMetas: WidgetRegistryMeta[] = [
{ key: 'music-player', name: 'Music Player', icon: Music, component: MusicPlayer },
];
+2 -1
View File
@@ -8,6 +8,7 @@
"./Pomodoro": "./Pomodoro/index.tsx",
"./DailyGoals": "./DailyGoals/index.tsx",
"./QuickNotes": "./QuickNotes/index.tsx",
"./Workspaces": "./Workspaces/index.tsx"
"./Workspaces": "./Workspaces/index.tsx",
"./MusicPlayer": "./MusicPlayer/index.tsx"
}
}