diff --git a/src/apps/officer-web/Screens/Dashboard/Music/MusicScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Music/MusicScreen.tsx index a0085b64..61e89c71 100644 --- a/src/apps/officer-web/Screens/Dashboard/Music/MusicScreen.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Music/MusicScreen.tsx @@ -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(['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 }; -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']; +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('screens/music', defaultLayout); - 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); + const workspace = useMemo(() => { + const fixed = normalizeLayout(rawWorkspace.value); + if (fixed === rawWorkspace.value) return rawWorkspace; + return { ...rawWorkspace, value: fixed }; + }, [rawWorkspace]); 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; + if (rawWorkspace.isLoaded && workspace.value !== rawWorkspace.value) { + rawWorkspace.setValue(workspace.value); } - 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 && ( - - )} -
- ); + }, [rawWorkspace.isLoaded, workspace.value, rawWorkspace.value]); 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/defaultLayout.ts b/src/apps/officer-web/Screens/Dashboard/Music/defaultLayout.ts new file mode 100644 index 00000000..a23618ba --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Music/defaultLayout.ts @@ -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 }, + ], +}; diff --git a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx index 718f7d84..5a0fc6e0 100644 --- a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx +++ b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx @@ -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); diff --git a/src/workspaces/officerdev/src/apps/Music/MusicBrowser.tsx b/src/workspaces/officerdev/src/apps/Music/MusicBrowser.tsx new file mode 100644 index 00000000..943b96b9 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Music/MusicBrowser.tsx @@ -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(MUSIC_CWD_CHANNEL, null); + const [libraries, setLibraries] = useState([]); + + useEffect(() => { + 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 currentLib = cwd ? cwd.split('/')[1] : null; + + return ( +
+ +
+ Libraries +
+ {libraries.map((lib) => ( + + ))} + {!libraries.length && No libraries} +
+ ); +}; diff --git a/src/workspaces/officerdev/src/apps/Music/MusicDetail.tsx b/src/workspaces/officerdev/src/apps/Music/MusicDetail.tsx new file mode 100644 index 00000000..a5db4b76 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Music/MusicDetail.tsx @@ -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(MUSIC_CWD_CHANNEL, null); + + const [manifest, setManifest] = useState>({}); + const [libraries, setLibraries] = useState([]); + 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 = 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(`/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(`/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 { + /* 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(`/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 }) => ( +
+ + {playable && ( + + )} +
+ ); + + return ( +
+ {cwd && ( + + )} + + {loading &&

Loading…

} + + {/* Home */} + {!cwd && ( +
+ {libraries.map((lib) => ( + + ))} +
+ )} + + {/* Album */} + {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 — discography sections */} + {disco && !album && ( +
+

{disco.artist}

+ {TYPE_ORDER.filter((type) => folders.some((f) => (disco.albums[f] ?? 'Other') === type)).map((type) => ( +
+

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

+
+ {folders + .filter((f) => (disco.albums[f] ?? 'Other') === type) + .map((f) => ( + 0} /> + ))} +
+
+ ))} +
+ )} + + {/* Grid */} + {!album && !disco && cwd && !loading && ( +
+ {folders.map((f) => ( + 0} /> + ))} + {!folders.length &&

Empty

} +
+ )} +
+ ); +}; diff --git a/src/workspaces/officerdev/src/apps/Music/index.ts b/src/workspaces/officerdev/src/apps/Music/index.ts new file mode 100644 index 00000000..35a061cc --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Music/index.ts @@ -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 }, +]; diff --git a/src/workspaces/officerdev/src/apps/Music/shared.ts b/src/workspaces/officerdev/src/apps/Music/shared.ts new file mode 100644 index 00000000..191f93a9 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Music/shared.ts @@ -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 }; +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 }; + +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) : '');