From ee6c7b0fddaa0e2653cf5582ef3b6549fec5dfd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Mon, 27 Jul 2026 13:40:33 +0000 Subject: [PATCH] music (web): Favorites view (inspired by the app's FavoritesScreen) A heart button in the left panel header opens a Favorites view in the right panel (coordinated via a new music:favorites panel channel). Grouped Artists / Albums / Tracks, each row: cover thumb (indexed cover, icon fallback) + title/subtitle + a heart to un-favorite. Click an album/artist to navigate the library there; click a track to play it in album context. Navigating anywhere closes the view (cwd-change effect). Empty state prompts to heart something. useMusicFavorites now also returns the grouped `favorites`; shared gains the channel + parseAlbumName. Co-Authored-By: Claude Opus 4.8 --- .../src/apps/Music/FavoritesView.tsx | 188 ++++++++++++++++++ .../src/apps/Music/MusicBrowser.tsx | 33 ++- .../officerdev/src/apps/Music/MusicDetail.tsx | 11 + .../officerdev/src/apps/Music/shared.ts | 8 + .../src/apps/Music/useMusicFavorites.ts | 2 +- 5 files changed, 232 insertions(+), 10 deletions(-) create mode 100644 src/workspaces/officerdev/src/apps/Music/FavoritesView.tsx 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 }; }