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:
@@ -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 },
|
||||
];
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user