diff --git a/src/workspaces/officerdev/src/apps/Music/FavoritesView.tsx b/src/workspaces/officerdev/src/apps/Music/FavoritesView.tsx new file mode 100644 index 00000000..ddc0226e --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Music/FavoritesView.tsx @@ -0,0 +1,188 @@ +import { useState, type ReactNode } from 'react'; +import { useClient } from 'hooks/useClient'; +import { usePanelChannel } from 'hooks/usePanelChannel'; +import { Heart, User, Disc3, Music, ChevronRight, X } from 'lucide-react'; +import { useMusicPlayer, type PlayerTrack } from '../../MusicPlayer'; +import { MusicHeart } from './MusicHeart'; +import { useMusicFavorites } from './useMusicFavorites'; +import { + MUSIC_ROOT, + MUSIC_CWD_CHANNEL, + MUSIC_FAV_CHANNEL, + coverUrl, + parseAlbumName, + sortTracks, + toRel, + type AlbumMeta, + type FavoriteKind, +} from './shared'; + +// The user's favorited artists / albums / tracks, grouped — shown in the right panel. Keys follow the +// favorites convention: album/artist are music-relative ("Albums/…"), tracks are home paths +// ("Music/…/file"). Clicking an album/artist navigates the library there; clicking a track plays it. +export const FavoritesView = () => { + const { get, token } = useClient(); + const player = useMusicPlayer(); + const { favorites } = useMusicFavorites(); + const [, setCwd] = usePanelChannel(MUSIC_CWD_CHANNEL, null); + const [, setFavOpen] = usePanelChannel(MUSIC_FAV_CHANNEL, false); + + const goToRel = (rel: string) => { + setCwd(`${MUSIC_ROOT}/${rel}`); + setFavOpen(false); + }; + + const playTrack = async (homePath: string) => { + const cut = homePath.lastIndexOf('/'); + const albumHome = homePath.slice(0, cut); + const file = homePath.slice(cut + 1); + const albumRel = toRel(albumHome); + try { + const meta = await get(`/music/meta?path=${encodeURIComponent(albumRel)}`); + const q: PlayerTrack[] = sortTracks(meta.tracks).map((t) => ({ albumRel, file: t.file, title: t.title, artist: t.artist })); + player.playQueue(q, Math.max(0, q.findIndex((t) => t.file === file))); + } catch { + player.playQueue([{ albumRel, file }], 0); + } + setCwd(albumHome); + setFavOpen(false); + }; + + const empty = !favorites.artists.length && !favorites.albums.length && !favorites.tracks.length; + + return ( +
+
+ +

Favorites

+ +
+ + {empty ? ( +
+ +

No favorites yet. Click the heart on any artist, album or track.

+
+ ) : ( +
+
+ {favorites.artists.map((key) => { + const segs = key.split('/'); + return ( + } + title={segs[segs.length - 1] ?? key} + onClick={() => goToRel(key)} + chevron + /> + ); + })} +
+ +
+ {favorites.albums.map((key) => { + const segs = key.split('/'); + const { title, year } = parseAlbumName(segs[segs.length - 1] ?? key); + const artist = segs.length >= 3 ? segs[1] : ''; + return ( + } + title={title} + subtitle={[artist, year].filter(Boolean).join(' · ')} + onClick={() => goToRel(key)} + chevron + /> + ); + })} +
+ +
+ {favorites.tracks.map((key) => { + const segs = key.split('/'); + const base = segs[segs.length - 1] ?? key; + const albumRel = toRel(key.slice(0, Math.max(0, key.lastIndexOf('/')))); + const album = segs.length >= 2 ? parseAlbumName(segs[segs.length - 2]!).title : ''; + return ( + } + title={base.replace(/\.[^/.]+$/, '')} + subtitle={album} + onClick={() => playTrack(key)} + /> + ); + })} +
+
+ )} +
+ ); +}; + +const Section = ({ title, count, children }: { title: string; count: number; children: ReactNode }) => + count ? ( +
+

+ {title} {count} +

+
{children}
+
+ ) : null; + +const FavRow = ({ + kind, + favKey, + cover, + fallback, + title, + subtitle, + onClick, + chevron, +}: { + kind: FavoriteKind; + favKey: string; + cover: string; + fallback: ReactNode; + title: string; + subtitle?: string; + onClick: () => void; + chevron?: boolean; +}) => { + const [failed, setFailed] = useState(false); + return ( +
+ + + {chevron ? : null} +
+ ); +}; diff --git a/src/workspaces/officerdev/src/apps/Music/MusicBrowser.tsx b/src/workspaces/officerdev/src/apps/Music/MusicBrowser.tsx index 31ae8a6f..05a372d7 100644 --- a/src/workspaces/officerdev/src/apps/Music/MusicBrowser.tsx +++ b/src/workspaces/officerdev/src/apps/Music/MusicBrowser.tsx @@ -1,10 +1,11 @@ import { useState, useEffect, type ReactNode } from 'react'; import { useClient } from 'hooks/useClient'; import { usePanelChannel } from 'hooks/usePanelChannel'; -import { Library, Music2, ChevronLeft, Folder, Search, X, RefreshCw } from 'lucide-react'; +import { Library, Music2, ChevronLeft, Folder, Search, X, RefreshCw, Heart } from 'lucide-react'; import { MUSIC_ROOT, MUSIC_CWD_CHANNEL, + MUSIC_FAV_CHANNEL, coverUrl, fuzzyMatch, toRel, @@ -39,6 +40,7 @@ const visibleDirs = (r: LsResult) => export const MusicBrowser = () => { const { get, post, token } = useClient(); const [cwd, setCwd] = usePanelChannel(MUSIC_CWD_CHANNEL, null); + const [favOpen, setFavOpen] = usePanelChannel(MUSIC_FAV_CHANNEL, false); const [manifest, setManifest] = useState>({}); const [libraries, setLibraries] = useState([]); const [folders, setFolders] = useState([]); @@ -109,14 +111,27 @@ export const MusicBrowser = () => { return (
- +
+ + +
diff --git a/src/workspaces/officerdev/src/apps/Music/MusicDetail.tsx b/src/workspaces/officerdev/src/apps/Music/MusicDetail.tsx index 9796f692..901800f6 100644 --- a/src/workspaces/officerdev/src/apps/Music/MusicDetail.tsx +++ b/src/workspaces/officerdev/src/apps/Music/MusicDetail.tsx @@ -3,11 +3,13 @@ import { useClient } from 'hooks/useClient'; import { usePanelChannel } from 'hooks/usePanelChannel'; import { Play, ChevronLeft, Volume2 } from 'lucide-react'; import { MusicHeart } from './MusicHeart'; +import { FavoritesView } from './FavoritesView'; import { useMusicPlayer } from '../../MusicPlayer'; import type { PlayerTrack } from '../../MusicPlayer'; import { MUSIC_ROOT, MUSIC_CWD_CHANNEL, + MUSIC_FAV_CHANNEL, TYPE_ORDER, coverUrl, fmtDuration, @@ -30,6 +32,7 @@ export const MusicDetail = () => { const { token, get } = useClient(); const player = useMusicPlayer(); const [cwd, setCwd] = usePanelChannel(MUSIC_CWD_CHANNEL, null); + const [favOpen, setFavOpen] = usePanelChannel(MUSIC_FAV_CHANNEL, false); const [manifest, setManifest] = useState>({}); const [libraries, setLibraries] = useState([]); @@ -54,6 +57,12 @@ export const MusicDetail = () => { } }, [player.current, cwd, setCwd]); + // Navigating anywhere (left panel or from within Favorites) closes the Favorites view. + useEffect(() => { + setFavOpen(false); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [cwd]); + useEffect(() => { get('/music/manifest') .then((m) => setManifest(m.albums)) @@ -169,6 +178,8 @@ export const MusicDetail = () => {
); + if (favOpen) return ; + return (
{cwd && ( diff --git a/src/workspaces/officerdev/src/apps/Music/shared.ts b/src/workspaces/officerdev/src/apps/Music/shared.ts index 1c23b688..4340e4c3 100644 --- a/src/workspaces/officerdev/src/apps/Music/shared.ts +++ b/src/workspaces/officerdev/src/apps/Music/shared.ts @@ -3,6 +3,14 @@ export const MUSIC_ROOT = 'Music'; export const MUSIC_CWD_CHANNEL = 'music:cwd'; +export const MUSIC_FAV_CHANNEL = 'music:favorites'; + +// Album folders are named "[year] Album Name" → display as "Album Name" + year. +const ALBUM_NAME_RE = /^\[(\d{4})\]\s*(.+)$/; +export const parseAlbumName = (name: string): { title: string; year?: string } => { + const m = ALBUM_NAME_RE.exec(name.trim()); + return m ? { title: m[2]!.trim(), year: m[1] } : { title: name }; +}; export type DirEntry = { name: string; type: 'directory' | 'file'; size: number; modifiedAt: number }; export type LsResult = { entries: DirEntry[] }; diff --git a/src/workspaces/officerdev/src/apps/Music/useMusicFavorites.ts b/src/workspaces/officerdev/src/apps/Music/useMusicFavorites.ts index 48252494..6ef3db7d 100644 --- a/src/workspaces/officerdev/src/apps/Music/useMusicFavorites.ts +++ b/src/workspaces/officerdev/src/apps/Music/useMusicFavorites.ts @@ -48,5 +48,5 @@ export function useMusicFavorites() { mutation.mutate({ on: !isFavorite(kind, key), kind, key }); }; - return { isFavorite, toggle }; + return { favorites: data ?? EMPTY, isFavorite, toggle }; }