diff --git a/src/apps/officer-web/App.tsx b/src/apps/officer-web/App.tsx index 64da4fd0..a2464964 100644 --- a/src/apps/officer-web/App.tsx +++ b/src/apps/officer-web/App.tsx @@ -40,6 +40,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx index 8fee719e..695afa42 100644 --- a/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx @@ -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']; diff --git a/src/apps/officer-web/Screens/Dashboard/Music/MusicScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Music/MusicScreen.tsx new file mode 100644 index 00000000..a0085b64 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Music/MusicScreen.tsx @@ -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 }; +type Track = { file: string; title?: string; artist?: string }; +type AlbumMeta = { path: string; cover?: string; tracks: Track[] }; +type Discography = { artist: string; albums: Record }; + +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>({}); + const [libraries, setLibraries] = useState([]); + const [cwd, setCwd] = useState(null); // home-relative path; null = library home + + const [folders, setFolders] = useState([]); + const [album, setAlbum] = useState(null); + const [disco, setDisco] = useState(null); + const [loading, setLoading] = useState(false); + + useEffect(() => { + get('/music/manifest') + .then((m) => setManifest(m.albums)) + .catch(() => setManifest({})); + 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 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(`/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(`/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(`/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(`/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 }) => ( +
+ + {playable && ( + + )} +
+ ); + + return ( +
+ {/* sidebar */} + + + {/* main */} +
+ {cwd && ( + + )} + + {loading &&

Loading…

} + + {/* Home: pick a library */} + {!cwd && ( +
+ {libraries.map((lib) => ( + + ))} +
+ )} + + {/* Album view */} + {album && ( +
+
+
+ { + (e.currentTarget as HTMLImageElement).style.visibility = 'hidden'; + }} + /> +
+
+

Album

+

{crumbs[crumbs.length - 1]}

+

+ {crumbs[crumbs.length - 2] ?? ''} · {album.length} songs +

+ +
+
+
+ {album.map((t, i) => ( + + ))} +
+
+ )} + + {/* Artist view — album cards grouped by discography type */} + {disco && !album && ( +
+

{disco.artist}

+ {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 ( +
+

+ {type === 'Studio' ? 'Studio Albums' : type} +

+
+ {inType.map((f) => ( + 0} /> + ))} +
+
+ ); + })} +
+ )} + + {/* Grid view — child folders as cards */} + {!album && !disco && cwd && !loading && ( +
+ {folders.map((f) => ( + 0} /> + ))} + {!folders.length &&

Empty

} +
+ )} +
+
+ ); +}; diff --git a/src/apps/officer-web/Screens/Dashboard/Music/index.tsx b/src/apps/officer-web/Screens/Dashboard/Music/index.tsx new file mode 100644 index 00000000..e66f2496 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Music/index.tsx @@ -0,0 +1 @@ +export * from './MusicScreen'; diff --git a/src/apps/officer-web/Screens/Dashboard/index.tsx b/src/apps/officer-web/Screens/Dashboard/index.tsx index 044c62b1..f8591d93 100644 --- a/src/apps/officer-web/Screens/Dashboard/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/index.tsx @@ -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';