|
|
|
@@ -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>
|
|
|
|
|
);
|
|
|
|
|
};
|