music: /music route + nav dock item (Spotify-style page, v1)

A full-page music browser reusing the app-wide player + dock. Sidebar lists
libraries (1st level of ~/Music); main is a card-grid folder browse with rich
pages: albums show a header + tracklist, and artist folders render their album
cards grouped into discography sections (Studio Albums / Live / Compilation / …)
using /music/discography. Cards have hover-play; everything feeds useMusicPlayer.
Adds a green Music dock item (/music) to the default dock.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 12:49:05 +00:00
co-authored by Claude Opus 4.8
parent 219cfba7ce
commit c3ed9158b3
5 changed files with 305 additions and 1 deletions
+1
View File
@@ -40,6 +40,7 @@ export function App() {
<Route path="/chat/:sessionId" element={<Dashboard.SessionListPage />} />
<Route path="/plans" element={<Dashboard.Plans />} />
<Route path="/files" element={<Dashboard.FilesScreen />} />
<Route path="/music" element={<Dashboard.MusicScreen />} />
<Route path="/code-editor" element={<Dashboard.CodeEditor />} />
<Route path="/skills" element={<Dashboard.Skills />} />
@@ -128,6 +128,7 @@ import {
Globe,
MonitorSmartphone,
Workflow,
Music,
} from 'lucide-react';
export const ALL_DOCK_ITEMS: DockItem[] = [
@@ -135,6 +136,7 @@ export const ALL_DOCK_ITEMS: DockItem[] = [
{ label: 'Files', to: '/files', icon: FolderOpen, color: '#fbbf24' },
{ label: 'Email', to: '/email', icon: Mail, color: '#ef4444' },
{ label: 'Chat', to: '/chat', icon: MessageCircle, color: '#60a5fa' },
{ label: 'Music', to: '/music', icon: Music, color: '#22c55e' },
{ label: 'Editor', to: '/code-editor', icon: Code, color: '#a78bfa' },
{ label: 'Plans', to: '/plans', icon: FileText, color: '#f472b6' },
{ label: 'Jobs', to: '/jobs', icon: Workflow, color: '#14b8a6' },
@@ -146,4 +148,4 @@ export const ALL_DOCK_ITEMS: DockItem[] = [
{ label: 'Dashboards', to: '/dashboards', icon: LayoutGrid, color: '#8b5cf6' },
];
export const DEFAULT_DOCK_PATHS = ['/', '/files', '/projects', '/dashboards', '/chat'];
export const DEFAULT_DOCK_PATHS = ['/', '/files', '/music', '/projects', '/dashboards', '/chat'];
@@ -0,0 +1,299 @@
import { useState, useEffect } from 'react';
import { useMusicPlayer } from 'officerdev';
import type { PlayerTrack } from 'officerdev';
import { useClient } from 'hooks/useClient';
import { Play, ChevronLeft, Library, Music2 } from 'lucide-react';
// Spotify-inspired /music page. Reuses the app-wide player (useMusicPlayer / the site-wide dock).
// Sidebar = libraries (1st level of ~/Music). Main = a folder browse rendered as cards, with rich
// pages for albums (tracklist) and artists (album cards grouped by discography type). v1 — iterate.
const MUSIC_ROOT = 'Music';
type DirEntry = { name: string; type: 'directory' | 'file'; size: number; modifiedAt: number };
type LsResult = { entries: DirEntry[] };
type ManifestAlbum = { v: string; cover: boolean; tracks: number; disco?: boolean };
type Manifest = { albums: Record<string, ManifestAlbum> };
type Track = { file: string; title?: string; artist?: string };
type AlbumMeta = { path: string; cover?: string; tracks: Track[] };
type Discography = { artist: string; albums: Record<string, string> };
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());
};
// Section order for an artist's discography.
const TYPE_ORDER = ['Studio', 'Live', 'Compilation', 'EP', 'Single', 'Soundtrack', 'Remix', 'DJ-Mix', 'Demo', 'Mixtape', 'Bootleg', 'Other'];
export const MusicScreen = () => {
const { token, get } = useClient();
const player = useMusicPlayer();
const [manifest, setManifest] = useState<Record<string, ManifestAlbum>>({});
const [libraries, setLibraries] = useState<string[]>([]);
const [cwd, setCwd] = useState<string | null>(null); // home-relative path; null = library home
const [folders, setFolders] = useState<string[]>([]);
const [album, setAlbum] = useState<Track[] | null>(null);
const [disco, setDisco] = useState<Discography | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
get<Manifest>('/music/manifest')
.then((m) => setManifest(m.albums))
.catch(() => setManifest({}));
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([]));
}, []);
const rel = cwd ? cwd.slice(MUSIC_ROOT.length + 1) : '';
const childRel = (name: string) => (rel ? `${rel}/${name}` : name);
const coverUrl = (r: string) =>
`/api/music/cover?path=${encodeURIComponent(r)}${token ? `&token=${encodeURIComponent(token)}` : ''}`;
// Load current folder → decide album / artist / grid view.
useEffect(() => {
if (!cwd) {
setFolders([]);
setAlbum(null);
setDisco(null);
return;
}
let cancelled = false;
setLoading(true);
setAlbum(null);
setDisco(null);
setFolders([]);
get<LsResult>(`/file-browser/ls?path=${encodeURIComponent(cwd)}`)
.then(async (r) => {
if (cancelled) return;
const dirs = 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);
setFolders(dirs);
if (audio.length) {
try {
const meta = await get<AlbumMeta>(`/music/meta?path=${encodeURIComponent(rel)}`);
if (!cancelled) setAlbum(meta.tracks);
} catch {
if (!cancelled) setAlbum(audio.sort().map((f) => ({ file: f })));
}
} else if (manifest[rel]?.disco) {
try {
const d = await get<Discography>(`/music/discography?path=${encodeURIComponent(rel)}`);
if (!cancelled) setDisco(d);
} catch {
/* fall back to plain grid */
}
}
})
.catch(() => {})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cwd, manifest]);
const enterLibrary = (lib: string) => setCwd(`${MUSIC_ROOT}/${lib}`);
const enter = (name: string) => setCwd(`${cwd}/${name}`);
const goUp = () => {
if (!cwd) return;
const parts = cwd.split('/');
setCwd(parts.length <= 2 ? null : parts.slice(0, -1).join('/'));
};
const playAlbum = async (albumRel: string, startIndex = 0) => {
try {
const meta = await get<AlbumMeta>(`/music/meta?path=${encodeURIComponent(albumRel)}`);
const queue: PlayerTrack[] = meta.tracks.map((t) => ({ albumRel, file: t.file, title: t.title, artist: t.artist }));
player.playQueue(queue, startIndex);
} catch {
/* ignore */
}
};
const playCurrent = (i: number) => {
if (!album) return;
const queue: PlayerTrack[] = album.map((t) => ({ albumRel: rel, file: t.file, title: t.title, artist: t.artist }));
player.playQueue(queue, i);
};
const isCurrent = (albumRel: string, file: string) => player.current?.albumRel === albumRel && player.current?.file === file;
const currentLib = cwd ? cwd.split('/')[1] : null;
const crumbs = rel ? rel.split('/') : [];
// ── Card ──
const Card = ({ r, name, playable }: { r: string; name: string; playable: boolean }) => (
<div className="group relative">
<button
type="button"
onClick={() => enter(name)}
className="flex w-full flex-col gap-2 rounded-lg bg-card/60 p-3 text-left transition-colors hover:bg-card"
>
<div className="aspect-square w-full overflow-hidden rounded-md bg-muted">
<img
src={coverUrl(r)}
alt=""
loading="lazy"
className="h-full w-full object-cover"
onError={(e) => {
(e.currentTarget as HTMLImageElement).style.visibility = 'hidden';
}}
/>
</div>
<span className="truncate text-sm font-medium text-foreground">{name}</span>
</button>
{playable && (
<button
type="button"
onClick={() => playAlbum(r, 0)}
className="absolute bottom-14 right-4 flex h-10 w-10 translate-y-2 items-center justify-center rounded-full bg-primary text-primary-foreground opacity-0 shadow-lg transition-all group-hover:translate-y-0 group-hover:opacity-100 hover:scale-105"
>
<Play size={18} className="ml-0.5" />
</button>
)}
</div>
);
return (
<div className="flex h-full">
{/* sidebar */}
<aside className="hidden w-56 shrink-0 flex-col gap-1 border-r border-border/50 bg-card/40 p-3 md:flex">
<div className="flex items-center gap-2 px-2 pb-2 text-foreground">
<Music2 size={18} className="text-primary" />
<span className="font-semibold">Music</span>
</div>
<div className="flex items-center gap-2 px-2 pb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
<Library size={13} /> Libraries
</div>
{libraries.map((lib) => (
<button
key={lib}
type="button"
onClick={() => enterLibrary(lib)}
className={`truncate rounded-md px-2 py-1.5 text-left text-sm ${
currentLib === lib ? 'bg-muted text-foreground' : 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'
}`}
>
{lib}
</button>
))}
</aside>
{/* main */}
<main className="min-w-0 flex-1 overflow-y-auto p-4 md:p-6">
{cwd && (
<button
type="button"
onClick={goUp}
className="mb-4 flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
>
<ChevronLeft size={16} />
<span className="truncate">{crumbs.length ? crumbs.join(' / ') : currentLib}</span>
</button>
)}
{loading && <p className="text-sm text-muted-foreground">Loading</p>}
{/* Home: pick a library */}
{!cwd && (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
{libraries.map((lib) => (
<button
key={lib}
type="button"
onClick={() => enterLibrary(lib)}
className="flex aspect-video items-end rounded-lg bg-gradient-to-br from-primary/30 to-primary/5 p-3 text-left text-lg font-semibold text-foreground transition-transform hover:scale-[1.02]"
>
{lib}
</button>
))}
</div>
)}
{/* Album view */}
{album && (
<div className="flex flex-col gap-5">
<div className="flex items-end gap-5">
<div className="h-40 w-40 shrink-0 overflow-hidden rounded-lg bg-muted shadow-lg">
<img
src={coverUrl(rel)}
alt=""
className="h-full w-full object-cover"
onError={(e) => {
(e.currentTarget as HTMLImageElement).style.visibility = 'hidden';
}}
/>
</div>
<div className="min-w-0">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Album</p>
<h1 className="truncate text-3xl font-bold text-foreground">{crumbs[crumbs.length - 1]}</h1>
<p className="mt-1 truncate text-sm text-muted-foreground">
{crumbs[crumbs.length - 2] ?? ''} · {album.length} songs
</p>
<button
type="button"
onClick={() => playCurrent(0)}
className="mt-3 flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground hover:scale-105"
>
<Play size={18} className="ml-0.5" />
</button>
</div>
</div>
<div className="flex flex-col">
{album.map((t, i) => (
<button
key={t.file}
type="button"
onClick={() => playCurrent(i)}
className={`flex items-center gap-4 rounded px-3 py-2 text-left hover:bg-muted ${
isCurrent(rel, t.file) ? 'text-primary' : 'text-foreground'
}`}
>
<span className="w-5 text-right text-sm tabular-nums text-muted-foreground">{i + 1}</span>
<span className="min-w-0 flex-1 truncate text-sm">{t.title ?? t.file}</span>
{t.artist && <span className="hidden truncate text-xs text-muted-foreground sm:block">{t.artist}</span>}
</button>
))}
</div>
</div>
)}
{/* Artist view — album cards grouped by discography type */}
{disco && !album && (
<div className="flex flex-col gap-6">
<h1 className="text-3xl font-bold text-foreground">{disco.artist}</h1>
{TYPE_ORDER.filter((type) => folders.some((f) => (disco.albums[f] ?? 'Other') === type)).map((type) => {
const inType = folders.filter((f) => (disco.albums[f] ?? 'Other') === type);
return (
<section key={type}>
<h2 className="mb-2 text-lg font-semibold text-foreground">
{type === 'Studio' ? 'Studio Albums' : type}
</h2>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
{inType.map((f) => (
<Card key={f} r={childRel(f)} name={f} playable={(manifest[childRel(f)]?.tracks ?? 0) > 0} />
))}
</div>
</section>
);
})}
</div>
)}
{/* Grid view — child folders as cards */}
{!album && !disco && cwd && !loading && (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
{folders.map((f) => (
<Card key={f} r={childRel(f)} name={f} playable={(manifest[childRel(f)]?.tracks ?? 0) > 0} />
))}
{!folders.length && <p className="text-sm text-muted-foreground">Empty</p>}
</div>
)}
</main>
</div>
);
};
@@ -0,0 +1 @@
export * from './MusicScreen';
@@ -10,6 +10,7 @@ export * from './TaskLogs';
export * from './Tasks';
export * from './Files';
export * from './Music';
export * from './CodeEditor';
export * from './ChatHistory';
export * from './Dashboards';