From 219cfba7cec339de09c224d0ab6ac2704275964e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sun, 26 Jul 2026 10:32:26 +0000 Subject: [PATCH] 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// vs DJ Sets/). Co-Authored-By: Claude Opus 4.8 --- src/workspaces/widgets/MusicPlayer/index.tsx | 246 +++++++++++-------- 1 file changed, 144 insertions(+), 102 deletions(-) diff --git a/src/workspaces/widgets/MusicPlayer/index.tsx b/src/workspaces/widgets/MusicPlayer/index.tsx index 8b113dc8..cb76fd77 100644 --- a/src/workspaces/widgets/MusicPlayer/index.tsx +++ b/src/workspaces/widgets/MusicPlayer/index.tsx @@ -1,151 +1,193 @@ 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 { 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 /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. +// 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_API = '/api/music'; +const MUSIC_ROOT = 'Music'; -type ManifestAlbum = { v: string; cover: boolean; tracks: number; disco?: boolean }; -type Manifest = { version: number; generatedAt: number; albums: Record }; +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(MUSIC_API); + const { token, get } = useClient(); // base '/api' const player = useMusicPlayer(); - const [albums, setAlbums] = useState([]); - const [query, setQuery] = useState(''); - const [albumRel, setAlbumRel] = useState(null); - const [tracks, setTracks] = useState([]); + const [libraries, setLibraries] = useState([]); + const [library, setLibrary] = useState(null); + const [cwd, setCwd] = useState(MUSIC_ROOT); // home-relative path + const [dirs, setDirs] = useState([]); + const [songs, setSongs] = useState([]); + const [loading, setLoading] = useState(false); + // Top-level libraries (once). useEffect(() => { - get('/manifest') - .then((m) => - setAlbums( - Object.entries(m.albums) - .filter(([, a]) => a.tracks > 0) - .map(([rel]) => rel) - .sort(), - ), - ) - .catch(() => setAlbums([])); + get(`/file-browser/ls?path=${encodeURIComponent(MUSIC_ROOT)}`) + .then((r) => setLibraries(r.entries.filter((e) => e.type === 'directory').map((e) => e.name).sort())) + .catch(() => setLibraries([])); }, []); - const filtered = useMemo(() => { - const q = query.trim().toLowerCase(); - return (q ? albums.filter((a) => a.toLowerCase().includes(q)) : albums).slice(0, 80); - }, [albums, query]); + // Current folder contents when cwd changes. + useEffect(() => { + if (!library) return; + let cancelled = false; + setLoading(true); + setDirs([]); + setSongs([]); + get(`/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); // /<…> + try { + const meta = await get(`/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 /<…> + const breadcrumb = relToMusic ? relToMusic.split('/') : []; 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) => { - try { - const meta = await get(`/meta?path=${encodeURIComponent(rel)}`); - setAlbumRel(rel); - setTracks(meta.tracks); - } catch { - /* ignore */ + 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 = (rel: string, ts: Track[], i: number) => { - const queue: PlayerTrack[] = ts.map((t) => ({ albumRel: rel, file: t.file, title: t.title, artist: t.artist })); + 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 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; + const isCurrent = (file: string) => player.current?.albumRel === relToMusic && player.current?.file === file; return ( - {!albumRel ? ( - // ── Browse albums ── -
-
- - setQuery(e.target.value)} - placeholder="Search albums…" - className="w-full bg-transparent py-1.5 text-sm outline-none placeholder:text-muted-foreground" - /> -
-
- {filtered.map((rel) => ( - - ))} - {!filtered.length && ( - No albums — reindex first? - )} -
-
+ {/* Library tabs */} +
+ {libraries.map((lib) => ( + + ))} + {!libraries.length && No libraries} +
+ + {!library ? ( +
Pick a library
) : ( - // ── Album track list ──
+ {/* breadcrumb / back */} -
-
- { - (e.currentTarget as HTMLImageElement).style.visibility = 'hidden'; - }} - /> + {/* album header (cover + play-all) when the folder has songs */} + {songs.length > 0 && ( +
+
+ { + (e.currentTarget as HTMLImageElement).style.visibility = 'hidden'; + }} + /> +
+
+

{breadcrumb[breadcrumb.length - 1] ?? library}

+

{breadcrumb[breadcrumb.length - 2] ?? ''}

+
+
-
-

{albumTitle(albumRel)}

-

{artistName(albumRel)}

-
- -
+ )} -
- {tracks.map((t, i) => ( + {/* folders + songs */} +
+ {dirs.map((d) => ( + + ))} + {songs.map((t, i) => ( ))} + {loading && Loading…} + {!loading && !dirs.length && !songs.length && ( + Empty + )}
)}