music: /music uses the Workspace/Panel system (2 vertical panels)

Split the screen into two registered panel apps that coordinate via a
'music:cwd' panel channel, like /chat:
- music-browser (left): library selector, publishes the path.
- music-detail (right): renders the path — album tracklist, artist discography
  sections, or a folder grid — and drives the app-wide player.
MusicScreen is now a WorkspaceView over a horizontal 2-panel layout (persisted as
screens/music), so the panels are resizable. Registered in AppRegistry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 12:59:56 +00:00
co-authored by Claude Opus 4.8
parent c3ed9158b3
commit 6fab49eddc
7 changed files with 403 additions and 286 deletions
@@ -1,299 +1,44 @@
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';
import { useEffect, useMemo } from 'react';
import type { LayoutNode } from 'officerdev';
import { WorkspaceView } from 'officerdev';
import { useDashboardState } from 'state/useDashboardState';
import { defaultLayout } from './defaultLayout';
// 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.
// /music uses the Workspace/Panel system (like /chat): two vertical panels — the library browser
// (music-browser) and the content/detail (music-detail) — coordinating via the 'music:cwd' channel.
const MUSIC_ROOT = 'Music';
const ALLOWED_APP_TYPES = new Set<string | null>(['music-browser', 'music-detail', null]);
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'];
function normalizeLayout(node: LayoutNode): LayoutNode {
if (node.type === 'panel') {
return ALLOWED_APP_TYPES.has(node.appType) ? node : { ...node, appType: 'music-detail' };
}
const children = node.children.map((c) => {
const fixed = normalizeLayout(c.node);
return fixed === c.node ? c : { ...c, node: fixed };
});
const changed = children.some((c, i) => c !== node.children[i]);
return changed ? { ...node, children } : node;
}
export const MusicScreen = () => {
const { token, get } = useClient();
const player = useMusicPlayer();
const rawWorkspace = useDashboardState<LayoutNode>('screens/music', defaultLayout);
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);
const workspace = useMemo(() => {
const fixed = normalizeLayout(rawWorkspace.value);
if (fixed === rawWorkspace.value) return rawWorkspace;
return { ...rawWorkspace, value: fixed };
}, [rawWorkspace]);
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;
if (rawWorkspace.isLoaded && workspace.value !== rawWorkspace.value) {
rawWorkspace.setValue(workspace.value);
}
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>
);
}, [rawWorkspace.isLoaded, workspace.value, rawWorkspace.value]);
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 className="h-full w-full pt-2">
<WorkspaceView workspace={workspace} locked />
</div>
);
};
@@ -0,0 +1,11 @@
import type { LayoutNode } from 'officerdev';
export const defaultLayout: LayoutNode = {
type: 'group',
id: 'music-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'music-browser', appType: 'music-browser' }, size: 26 },
{ node: { type: 'panel', id: 'music-detail', appType: 'music-detail' }, size: 74 },
],
};
@@ -10,13 +10,14 @@ import { appRegistryMetas as chatHistoryMetas } from '../apps/ChatHistory';
import { appRegistryMetas as previewMetas } from '../apps/Preview';
import { appRegistryMetas as widgetMetas } from '../apps/Widgets';
import { appRegistryMetas as desktopMetas } from '../apps/Desktop';
import { appRegistryMetas as musicMetas } from '../apps/Music';
import { useAppRegistry } from './useAppRegistry';
import { useUserApps } from 'state/useUserApps';
import { createUserAppPanel } from '../apps/UserApp/UserAppPanel';
import { createUserAppHeader } from '../apps/UserApp/UserAppHeader';
import { resolveIcon } from '../utils/resolve-icon';
const apps = [...fileBrowserMetas, ...terminalMetas, ...codeEditorMetas, ...chatMetas, ...fileViewerMetas, ...dashboardMetas, ...projectMetas, ...chatHistoryMetas, ...previewMetas, ...widgetMetas, ...desktopMetas];
const apps = [...fileBrowserMetas, ...terminalMetas, ...codeEditorMetas, ...chatMetas, ...fileViewerMetas, ...dashboardMetas, ...projectMetas, ...chatHistoryMetas, ...previewMetas, ...widgetMetas, ...desktopMetas, ...musicMetas];
export const AppRegistry = () => {
const { registerApp } = useAppRegistry(apps);
@@ -0,0 +1,50 @@
import { useState, useEffect } from 'react';
import { useClient } from 'hooks/useClient';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { Library, Music2 } from 'lucide-react';
import { MUSIC_ROOT, MUSIC_CWD_CHANNEL, type LsResult } from './shared';
// Left panel of the /music workspace — the library selector. Publishes the chosen path to the
// 'music:cwd' channel; MusicDetail (right panel) renders it.
export const MusicBrowser = () => {
const { get } = useClient();
const [cwd, setCwd] = usePanelChannel<string | null>(MUSIC_CWD_CHANNEL, null);
const [libraries, setLibraries] = useState<string[]>([]);
useEffect(() => {
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 currentLib = cwd ? cwd.split('/')[1] : null;
return (
<div className="flex h-full flex-col gap-1 overflow-y-auto p-3">
<button
type="button"
onClick={() => setCwd(null)}
className="flex items-center gap-2 px-2 pb-2 text-left text-foreground"
>
<Music2 size={18} className="text-primary" />
<span className="font-semibold">Music</span>
</button>
<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={() => setCwd(`${MUSIC_ROOT}/${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>
))}
{!libraries.length && <span className="px-2 text-sm text-muted-foreground">No libraries</span>}
</div>
);
};
@@ -0,0 +1,258 @@
import { useState, useEffect } from 'react';
import { useClient } from 'hooks/useClient';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { Play, ChevronLeft } from 'lucide-react';
import { useMusicPlayer } from '../../MusicPlayer';
import type { PlayerTrack } from '../../MusicPlayer';
import {
MUSIC_ROOT,
MUSIC_CWD_CHANNEL,
TYPE_ORDER,
coverUrl,
isAudio,
toRel,
type AlbumMeta,
type Discography,
type LsResult,
type Manifest,
type ManifestAlbum,
type Track,
} from './shared';
// Right panel of the /music workspace — renders the content of the current 'music:cwd': an album
// (tracklist), an artist (album cards grouped by discography type), or a folder grid. Drilling in
// updates the shared channel; playback goes through the app-wide player.
export const MusicDetail = () => {
const { token, get } = useClient();
const player = useMusicPlayer();
const [cwd, setCwd] = usePanelChannel<string | null>(MUSIC_CWD_CHANNEL, null);
const [manifest, setManifest] = useState<Record<string, ManifestAlbum>>({});
const [libraries, setLibraries] = useState<string[]>([]);
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 = toRel(cwd);
const childRel = (name: string) => (rel ? `${rel}/${name}` : name);
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;
setFolders(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) {
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 {
/* plain grid */
}
}
})
.catch(() => {})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cwd, manifest]);
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 crumbs = rel ? rel.split('/') : [];
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, token)}
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="h-full 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(' / ') : cwd.split('/')[1]}</span>
</button>
)}
{loading && <p className="text-sm text-muted-foreground">Loading</p>}
{/* Home */}
{!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={() => setCwd(`${MUSIC_ROOT}/${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 */}
{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, token)}
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 — discography sections */}
{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) => (
<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">
{folders
.filter((f) => (disco.albums[f] ?? 'Other') === type)
.map((f) => (
<Card key={f} r={childRel(f)} name={f} playable={(manifest[childRel(f)]?.tracks ?? 0) > 0} />
))}
</div>
</section>
))}
</div>
)}
{/* Grid */}
{!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>
)}
</div>
);
};
@@ -0,0 +1,11 @@
import type { AppRegistryMeta } from '../../AppRegistry';
import { Music, ListMusic } from 'lucide-react';
import { MusicBrowser } from './MusicBrowser';
import { MusicDetail } from './MusicDetail';
export { MusicBrowser, MusicDetail };
export const appRegistryMetas: AppRegistryMeta[] = [
{ key: 'music-browser', name: 'Library', icon: ListMusic, component: MusicBrowser, availableOnPanel: false },
{ key: 'music-detail', name: 'Music', icon: Music, component: MusicDetail, availableOnPanel: false },
];
@@ -0,0 +1,41 @@
// Shared types/helpers for the /music workspace panels (MusicBrowser + MusicDetail), which coordinate
// via the 'music:cwd' panel channel and play through the app-wide useMusicPlayer.
export const MUSIC_ROOT = 'Music';
export const MUSIC_CWD_CHANNEL = 'music:cwd';
export type DirEntry = { name: string; type: 'directory' | 'file'; size: number; modifiedAt: number };
export type LsResult = { entries: DirEntry[] };
export type ManifestAlbum = { v: string; cover: boolean; tracks: number; disco?: boolean };
export type Manifest = { albums: Record<string, ManifestAlbum> };
export type Track = { file: string; title?: string; artist?: string };
export type AlbumMeta = { path: string; cover?: string; tracks: Track[] };
export type Discography = { artist: string; albums: Record<string, string> };
const AUDIO_EXT = new Set(['mp3', 'flac', 'm4a', 'aac', 'ogg', 'opus', 'wav', 'wma', 'mp4']);
export 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.
export const TYPE_ORDER = [
'Studio',
'Live',
'Compilation',
'EP',
'Single',
'Soundtrack',
'Remix',
'DJ-Mix',
'Demo',
'Mixtape',
'Bootleg',
'Other',
];
export const coverUrl = (rel: string, token: string | null) =>
`/api/music/cover?path=${encodeURIComponent(rel)}${token ? `&token=${encodeURIComponent(token)}` : ''}`;
/** Path (home-relative) → rel (relative to the Music root). */
export const toRel = (cwd: string | null) => (cwd ? cwd.slice(MUSIC_ROOT.length + 1) : '');