import type { ReactNode } from 'react'; import { createContext, useContext, useState, useEffect, useRef } from 'react'; import { Link, useNavigate } from 'react-router'; import { useClient } from 'hooks/useClient'; import { usePanelChannel } from 'hooks/usePanelChannel'; import { Play, Pause, ChevronLeft, MicVocal, Volume2 } from 'lucide-react'; import type { LayoutNode, PanelComponents } from 'officerdev'; import { WorkspaceLayout } from 'officerdev'; import { MusicHeart } from 'officerdev'; import { FavoritesView } from './FavoritesView'; import { useMusicPlayer } from 'officerdev'; import type { PlayerTrack } from 'officerdev'; import { LyricsPanel } from './LyricsPanel'; import { MusicMiniBar } from './MusicMiniBar'; import { useLyricsOpen } from 'officerdev'; import { MUSIC_ROOT, MUSIC_FAV_CHANNEL, MUSIC_RESYNC_CHANNEL, TYPE_ORDER, coverUrl, fmtDuration, isAudio, musicParentPath, musicPath, sortTracks, toRel, trackHomePath, useMusicCwd, type AlbumMeta, type Discography, type LsResult, type Manifest, type ManifestAlbum, type Track, } from './shared'; // Turning the lyrics on splits THIS panel in two rather than opening a panel of its own: the workspace // system is a layout engine as well as a shell, so a nested WorkspaceLayout with a fixed layout and // components keyed by panel id gets a resizable split with no persistence and no registry entries. const LYRICS_LAYOUT: LayoutNode = { type: 'group', id: 'music-detail-split', direction: 'horizontal', children: [ { node: { type: 'panel', id: 'music-detail-list', appType: null }, size: 62 }, { node: { type: 'panel', id: 'music-detail-lyrics', appType: null }, size: 38 }, ], }; // The library view is rendered by MusicDetail itself and passed down through context, so toggling the // split moves the same element rather than mounting a second copy — the fetched album, and every request // that produced it, survives the toggle. const LibraryViewContext = createContext(null); const LibraryViewPanel = () => <>{useContext(LibraryViewContext)}; const LYRICS_PANELS: PanelComponents = { 'music-detail-list': LibraryViewPanel, 'music-detail-lyrics': LyricsPanel, }; const keepLayout = () => {}; // Right panel of the /music workspace — renders the content of `/music?path=…`: an album (tracklist), // an artist (album cards grouped by discography type), or a folder grid. Drilling in is a link, so it // changes the address; playback goes through the app-wide player. export const MusicDetail = () => { const { token, get } = useClient(); const navigate = useNavigate(); const player = useMusicPlayer(); const cwd = useMusicCwd(); const [favOpen, setFavOpen] = usePanelChannel(MUSIC_FAV_CHANNEL, false); const [resync] = usePanelChannel(MUSIC_RESYNC_CHANNEL, 0); const [lyricsOpen, toggleLyrics] = useLyricsOpen(); 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); // On first mount with no location yet, open the currently-playing album — so a reload/return lands on // the track you were listening to (the player itself restores via the saved now-playing snapshot). // Once only, so it never yanks you back after you navigate away (e.g. up to the library root). const autoNavRef = useRef(false); useEffect(() => { if (autoNavRef.current) return; if (cwd) { autoNavRef.current = true; return; } if (player.current) { autoNavRef.current = true; // `replace`: landing on /music and being moved to the playing album is one arrival, not two, so // Back should leave the screen rather than undo a jump the user never asked for. navigate(musicPath(player.current.albumRel), { replace: true }); } }, [player.current, cwd, navigate]); // Navigating anywhere (left panel or from within Favorites) closes the Favorites view. useEffect(() => { setFavOpen(false); // eslint-disable-next-line react-hooks/exhaustive-deps }, [cwd]); // Refetch manifest + libraries on mount and whenever a reindex bumps the resync nonce. A fresh manifest // object identity also re-runs the [cwd, manifest] listing effect below, refreshing the folder grid / // album meta for whatever is currently open — so the right panel updates in place, no nav required. 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' && !e.name.startsWith('.')) .map((e) => e.name) .sort(), ), ) .catch(() => setLibraries([])); }, [resync]); 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' && !e.name.startsWith('.')) .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(sortTracks(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 playAlbum = async (albumRel: string, startIndex = 0) => { try { const meta = await get(`/music/meta?path=${encodeURIComponent(albumRel)}`); const queue: PlayerTrack[] = sortTracks(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; // The album's play button is the dock's play button when the dock is already on this album: same // useGlobal state, so it shows pause while it plays and resumes where it stopped. It only starts the // album from the top when something else (or nothing) is loaded. const albumLoaded = !!album && player.current?.albumRel === rel; const albumPlaying = albumLoaded && player.playing; const toggleAlbum = () => (albumLoaded ? player.toggle() : playCurrent(0)); const crumbs = rel ? rel.split('/') : []; // `r` is already the child's rel, so it is both the cover key and the link target — a library root and // a nested album need no different treatment. Play and heart are siblings of the anchor, never inside it. const Card = ({ r, name, playable }: { r: string; name: string; playable: boolean }) => (
{ (e.currentTarget as HTMLImageElement).style.visibility = 'hidden'; }} />
{name} {playable && ( )} {playable && ( )}
); const content = favOpen ? (
) : (
{cwd && ( {crumbs.length ? crumbs.join(' / ') : cwd.split('/')[1]} )} {loading &&

Loading…

} {/* Home — the library roots carry their own folder art (the manifest indexes them like any other folder), so they get the same cards as everything else rather than a wall of flat tiles. */} {!cwd && (
{libraries.map((lib) => ( 0} /> ))}
)} {/* 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) => { const cur = isCurrent(rel, t.file); const artist = t.artist ?? t.albumArtist ?? ''; const dur = fmtDuration(t.durationSec); return (
); })}
)} {/* 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 — a library root, or an artist folder (crumbs>=2) whose albums aren't grouped by a discography. Show an artist header + heart on the latter. */} {!album && !disco && cwd && !loading && (
{crumbs.length >= 2 && (

{crumbs[crumbs.length - 1]}

)}
{folders.map((f) => ( 0} /> ))} {!folders.length &&

Empty

}
)}
); // The scrubber sits at the foot of this panel instead of the app-wide dock, which hides itself on // /music: the album view already carries the transport, so all the dock added here was a second row. const libraryView = (
{content}
); if (!lyricsOpen) return libraryView; return ( ); };