music: widget becomes a library/folder browser (tabs + Artist/Album/Song)
Top-level dirs of ~/Music are libraries (tabs); within one you drill through folders via /file-browser/ls with a breadcrumb until a folder has tracks, then its songs (titled from the indexed /music/meta, filenames as fallback) with a cover + play-all. Selecting a track feeds the app-wide player. Handles the non-uniform library layouts (Albums/<Artist>/<Album> vs DJ Sets/<Artist>). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,151 +1,193 @@
|
|||||||
import type { WidgetRegistryMeta, PlayerTrack } from 'officerdev';
|
import type { WidgetRegistryMeta, PlayerTrack } from 'officerdev';
|
||||||
import { useMusicPlayer } from 'officerdev';
|
import { useMusicPlayer } from 'officerdev';
|
||||||
import { useState, useEffect, useMemo } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Music, Search, ChevronLeft, Play } from 'lucide-react';
|
import { Music, ChevronLeft, Play, Folder } from 'lucide-react';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { Widget } from '../Widget';
|
import { Widget } from '../Widget';
|
||||||
|
|
||||||
// Music Player widget — a BROWSER over the /api/music/* library. Playback is owned by the app-wide
|
// Music Player widget — a BROWSER over the library. Top-level dirs of ~/Music are "libraries" (tabs);
|
||||||
// player (useMusicPlayer / MusicPlayerHost): selecting a track hands a queue to the global player, which
|
// within a library you drill through folders (Artist → Albums → …) until a folder has tracks (songs).
|
||||||
// keeps playing across route changes and shows the site-wide dock.
|
// Playback is owned by the app-wide player (useMusicPlayer): selecting a track hands it a queue.
|
||||||
|
|
||||||
const MUSIC_API = '/api/music';
|
const MUSIC_ROOT = 'Music';
|
||||||
|
|
||||||
type ManifestAlbum = { v: string; cover: boolean; tracks: number; disco?: boolean };
|
type DirEntry = { name: string; type: 'directory' | 'file'; size: number; modifiedAt: number };
|
||||||
type Manifest = { version: number; generatedAt: number; albums: Record<string, ManifestAlbum> };
|
type LsResult = { path: string; rootDir?: string; entries: DirEntry[] };
|
||||||
type Track = { file: string; title?: string; artist?: string };
|
type Track = { file: string; title?: string; artist?: string };
|
||||||
type AlbumMeta = { path: string; cover?: string; tracks: Track[] };
|
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 = () => {
|
export const MusicPlayer = () => {
|
||||||
const { token, get } = useClient(MUSIC_API);
|
const { token, get } = useClient(); // base '/api'
|
||||||
const player = useMusicPlayer();
|
const player = useMusicPlayer();
|
||||||
|
|
||||||
const [albums, setAlbums] = useState<string[]>([]);
|
const [libraries, setLibraries] = useState<string[]>([]);
|
||||||
const [query, setQuery] = useState('');
|
const [library, setLibrary] = useState<string | null>(null);
|
||||||
const [albumRel, setAlbumRel] = useState<string | null>(null);
|
const [cwd, setCwd] = useState<string>(MUSIC_ROOT); // home-relative path
|
||||||
const [tracks, setTracks] = useState<Track[]>([]);
|
const [dirs, setDirs] = useState<string[]>([]);
|
||||||
|
const [songs, setSongs] = useState<Track[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// Top-level libraries (once).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
get<Manifest>('/manifest')
|
get<LsResult>(`/file-browser/ls?path=${encodeURIComponent(MUSIC_ROOT)}`)
|
||||||
.then((m) =>
|
.then((r) => setLibraries(r.entries.filter((e) => e.type === 'directory').map((e) => e.name).sort()))
|
||||||
setAlbums(
|
.catch(() => setLibraries([]));
|
||||||
Object.entries(m.albums)
|
|
||||||
.filter(([, a]) => a.tracks > 0)
|
|
||||||
.map(([rel]) => rel)
|
|
||||||
.sort(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.catch(() => setAlbums([]));
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
// Current folder contents when cwd changes.
|
||||||
const q = query.trim().toLowerCase();
|
useEffect(() => {
|
||||||
return (q ? albums.filter((a) => a.toLowerCase().includes(q)) : albums).slice(0, 80);
|
if (!library) return;
|
||||||
}, [albums, query]);
|
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) =>
|
const coverUrl = (rel: string) =>
|
||||||
`${MUSIC_API}/cover?path=${encodeURIComponent(rel)}${token ? `&token=${encodeURIComponent(token)}` : ''}`;
|
`/api/music/cover?path=${encodeURIComponent(rel)}${token ? `&token=${encodeURIComponent(token)}` : ''}`;
|
||||||
|
|
||||||
const openAlbum = async (rel: string) => {
|
const selectLibrary = (lib: string) => {
|
||||||
try {
|
setLibrary(lib);
|
||||||
const meta = await get<AlbumMeta>(`/meta?path=${encodeURIComponent(rel)}`);
|
setCwd(`${MUSIC_ROOT}/${lib}`);
|
||||||
setAlbumRel(rel);
|
};
|
||||||
setTracks(meta.tracks);
|
const enter = (name: string) => setCwd(`${cwd}/${name}`);
|
||||||
} catch {
|
const goUp = () => {
|
||||||
/* ignore */
|
const parts = cwd.split('/');
|
||||||
|
if (parts.length <= 2) {
|
||||||
|
setLibrary(null);
|
||||||
|
setCwd(MUSIC_ROOT);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
setCwd(parts.slice(0, -1).join('/'));
|
||||||
};
|
};
|
||||||
|
|
||||||
const play = (rel: string, ts: Track[], i: number) => {
|
const play = (i: number) => {
|
||||||
const queue: PlayerTrack[] = ts.map((t) => ({ albumRel: rel, file: t.file, title: t.title, artist: t.artist }));
|
const queue: PlayerTrack[] = songs.map((t) => ({ albumRel: relToMusic, file: t.file, title: t.title, artist: t.artist }));
|
||||||
player.playQueue(queue, i);
|
player.playQueue(queue, i);
|
||||||
};
|
};
|
||||||
|
const isCurrent = (file: string) => player.current?.albumRel === relToMusic && player.current?.file === file;
|
||||||
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 (
|
return (
|
||||||
<Widget title="Music Player" className="w-72">
|
<Widget title="Music Player" className="w-72">
|
||||||
{!albumRel ? (
|
{/* Library tabs */}
|
||||||
// ── Browse albums ──
|
<div className="flex gap-1 overflow-x-auto px-3 pb-2">
|
||||||
<div className="flex flex-col gap-2 px-3 pb-3">
|
{libraries.map((lib) => (
|
||||||
<div className="flex items-center gap-2 rounded-md bg-muted px-2">
|
<button
|
||||||
<Search size={14} className="text-muted-foreground" />
|
key={lib}
|
||||||
<input
|
type="button"
|
||||||
value={query}
|
onClick={() => selectLibrary(lib)}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
className={`shrink-0 rounded-full px-2.5 py-1 text-xs ${
|
||||||
placeholder="Search albums…"
|
library === lib ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground hover:text-foreground'
|
||||||
className="w-full bg-transparent py-1.5 text-sm outline-none placeholder:text-muted-foreground"
|
}`}
|
||||||
/>
|
>
|
||||||
</div>
|
{lib}
|
||||||
<div className="flex max-h-72 flex-col overflow-y-auto">
|
</button>
|
||||||
{filtered.map((rel) => (
|
))}
|
||||||
<button
|
{!libraries.length && <span className="px-1 text-xs text-muted-foreground">No libraries</span>}
|
||||||
key={rel}
|
</div>
|
||||||
type="button"
|
|
||||||
onClick={() => openAlbum(rel)}
|
{!library ? (
|
||||||
className="flex max-w-full flex-col items-start rounded px-2 py-1.5 text-left hover:bg-muted"
|
<div className="px-3 pb-4 text-center text-sm text-muted-foreground">Pick a library</div>
|
||||||
>
|
|
||||||
<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">
|
<div className="flex flex-col gap-2 px-3 pb-3">
|
||||||
|
{/* breadcrumb / back */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setAlbumRel(null)}
|
onClick={goUp}
|
||||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
className="flex items-center gap-1 truncate text-xs text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
<ChevronLeft size={14} /> Library
|
<ChevronLeft size={14} className="shrink-0" />
|
||||||
|
<span className="truncate">{breadcrumb.length ? breadcrumb.join(' / ') : library}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
{/* album header (cover + play-all) when the folder has songs */}
|
||||||
<div className="flex h-14 w-14 shrink-0 items-center justify-center overflow-hidden rounded-md bg-muted">
|
{songs.length > 0 && (
|
||||||
<img
|
<div className="flex items-center gap-3">
|
||||||
src={coverUrl(albumRel)}
|
<div className="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-md bg-muted">
|
||||||
alt=""
|
<img
|
||||||
className="h-full w-full object-cover"
|
src={coverUrl(relToMusic)}
|
||||||
onError={(e) => {
|
alt=""
|
||||||
(e.currentTarget as HTMLImageElement).style.visibility = 'hidden';
|
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>
|
</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">
|
{/* folders + songs */}
|
||||||
{tracks.map((t, i) => (
|
<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
|
<button
|
||||||
key={t.file}
|
key={t.file}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => play(albumRel, tracks, i)}
|
onClick={() => play(i)}
|
||||||
className={`flex items-center gap-2 rounded px-2 py-1 text-left text-xs hover:bg-muted ${
|
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'
|
isCurrent(t.file) ? 'text-primary' : 'text-muted-foreground'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="w-4 text-right tabular-nums">{i + 1}</span>
|
<span className="w-4 shrink-0 text-right tabular-nums">{i + 1}</span>
|
||||||
<span className="truncate">{t.title ?? t.file}</span>
|
<span className="truncate">{t.title ?? t.file}</span>
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user