officerdev/src/MusicPlayer/ → plugins/music/web/. Engine, state, bar, favourites, lyrics toggle and the library vocabulary — ten files. The barrel stops exporting a player it no longer has, and DashboardLayout stops rendering one. The reasoning that kept it was removed rather than refuted. It stayed because the dashboard widget imported useMusicPlayer from officerdev and the platform cannot import from a plugin, so the state had to stay whatever was decided about the UI. The owner moved the widget into the plugin in the previous commit, and the constraint went with it: the whole remaining dependency became one line, DashboardLayout.tsx:66. MusicPlayerHost is mounted inside the MusicDetail panel. That reads odd until you notice it already returned null on /music — the mini bar is the transport there, and the host existed purely to own the GaplessEngine. In the panel it does exactly that, and the bar code stays intact for whenever there is a slot. [phase 2] Leaving /music unmounts the host and playback stops. Deferred on the owner's call; the bar was "navigating away must not break the application", and that holds: seekPlayer is optional-chained so a call with no host registered is a no-op, registerPlayerSeek clears only its own registration, the host's cleanup destroys the engine and nulls its ref, and the queue is global state so returning to /music remounts and reloads. Solving it properly needs either a shell slot a plugin can contribute to — which reopens "there is no way to export a component" — or the engine hoisted to module scope, which keeps the rule and loses only the off-route controls. Also: the parked widget now imports the player as a sibling rather than through officerdev, and shared.ts stopped being a re-export shim now that the real file is in the plugin. Verified: tsgo clean, 797 tests / 787 pass / same 7. Server restarts, mounts /example /music /offscale, / and /music both 200, and the player is in the built bundle (music.volume, music:lyrics, now-playing?device=web all present — GaplessEngine is a class name and the production build is minified, so grepping for it proves nothing). Not verified by me: what it looks like in a browser. That needs your eyes.
439 lines
18 KiB
TypeScript
439 lines
18 KiB
TypeScript
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 './MusicHeart';
|
|
import { FavoritesView } from './FavoritesView';
|
|
import { useMusicPlayer } from './useMusicPlayer';
|
|
import type { PlayerTrack } from './useMusicPlayer';
|
|
import { LyricsPanel } from './LyricsPanel';
|
|
import { MusicMiniBar } from './MusicMiniBar';
|
|
import { MusicPlayerHost } from './MusicPlayerHost';
|
|
import { useLyricsOpen } from './useLyricsOpen';
|
|
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<ReactNode>(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<boolean>(MUSIC_FAV_CHANNEL, false);
|
|
const [resync] = usePanelChannel<number>(MUSIC_RESYNC_CHANNEL, 0);
|
|
const [lyricsOpen, toggleLyrics] = useLyricsOpen();
|
|
|
|
const [manifest, setManifest] = useState<Record<string, ManifestAlbum>>({});
|
|
const [libraries, setLibraries] = useState<string[]>([]);
|
|
const [folders, setFolders] = useState<string[]>([]);
|
|
const [album, setAlbum] = useState<Track[] | null>(null);
|
|
const [disco, setDisco] = useState<Discography | null>(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<Manifest>('/music/manifest')
|
|
.then((m) => setManifest(m.albums))
|
|
.catch(() => setManifest({}));
|
|
get<LsResult>(`/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<LsResult>(`/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<AlbumMeta>(`/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<Discography>(`/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<AlbumMeta>(`/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 }) => (
|
|
<div className="group relative">
|
|
<Link
|
|
to={musicPath(r)}
|
|
className="flex w-full flex-col gap-2 rounded-lg bg-card/60 p-3 text-left transition-colors hover:bg-card"
|
|
>
|
|
<div className="aspect-square w-full overflow-hidden rounded-md bg-muted">
|
|
<img
|
|
src={coverUrl(r, token)}
|
|
alt=""
|
|
loading="lazy"
|
|
className="h-full w-full object-cover"
|
|
onError={(e) => {
|
|
(e.currentTarget as HTMLImageElement).style.visibility = 'hidden';
|
|
}}
|
|
/>
|
|
</div>
|
|
<span className="truncate text-sm font-medium text-foreground">{name}</span>
|
|
</Link>
|
|
{playable && (
|
|
<button
|
|
type="button"
|
|
onClick={() => playAlbum(r, 0)}
|
|
className="absolute bottom-14 right-4 flex h-10 w-10 translate-y-2 items-center justify-center rounded-full bg-primary text-primary-foreground opacity-0 shadow-lg transition-all group-hover:translate-y-0 group-hover:opacity-100 hover:scale-105"
|
|
>
|
|
<Play size={18} className="ml-0.5" />
|
|
</button>
|
|
)}
|
|
{playable && (
|
|
<MusicHeart
|
|
kind="album"
|
|
favKey={r}
|
|
size={18}
|
|
hoverReveal
|
|
className="absolute right-2 top-2 rounded-full bg-black/40 p-1.5 text-white"
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
const content = favOpen ? (
|
|
<div className="min-h-0 flex-1">
|
|
<FavoritesView />
|
|
</div>
|
|
) : (
|
|
<div className="min-h-0 flex-1 overflow-y-auto p-4 md:p-6">
|
|
{cwd && (
|
|
<Link
|
|
to={musicParentPath(rel)}
|
|
className="mb-4 flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
|
|
>
|
|
<ChevronLeft size={16} />
|
|
<span className="truncate">{crumbs.length ? crumbs.join(' / ') : cwd.split('/')[1]}</span>
|
|
</Link>
|
|
)}
|
|
|
|
{loading && <p className="text-sm text-muted-foreground">Loading…</p>}
|
|
|
|
{/* 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 && (
|
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
|
{libraries.map((lib) => (
|
|
<Card key={lib} r={lib} name={lib} playable={(manifest[lib]?.tracks ?? 0) > 0} />
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Album */}
|
|
{album && (
|
|
<div className="flex flex-col gap-5">
|
|
<div className="flex items-end gap-5">
|
|
<div className="h-40 w-40 shrink-0 overflow-hidden rounded-lg bg-muted shadow-lg">
|
|
<img
|
|
src={coverUrl(rel, token)}
|
|
alt=""
|
|
className="h-full w-full object-cover"
|
|
onError={(e) => {
|
|
(e.currentTarget as HTMLImageElement).style.visibility = 'hidden';
|
|
}}
|
|
/>
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Album</p>
|
|
<h1 className="truncate text-3xl font-bold text-foreground">{crumbs[crumbs.length - 1]}</h1>
|
|
<p className="mt-1 truncate text-sm text-muted-foreground">
|
|
{crumbs[crumbs.length - 2] ?? ''} · {album.length} songs
|
|
</p>
|
|
<div className="mt-3 flex items-center gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={toggleAlbum}
|
|
title={albumPlaying ? 'Pause' : 'Play'}
|
|
className="flex h-10 w-10 cursor-pointer items-center justify-center rounded-full bg-primary text-primary-foreground hover:scale-105"
|
|
>
|
|
{albumPlaying ? <Pause size={18} /> : <Play size={18} className="ml-0.5" />}
|
|
</button>
|
|
<MusicHeart kind="album" favKey={rel} size={24} className="p-1" />
|
|
<button
|
|
type="button"
|
|
onClick={toggleLyrics}
|
|
title={lyricsOpen ? 'Hide lyrics' : 'Show lyrics'}
|
|
aria-pressed={lyricsOpen}
|
|
className={`cursor-pointer p-1 hover:text-foreground ${
|
|
lyricsOpen ? 'text-primary' : 'text-muted-foreground'
|
|
}`}
|
|
>
|
|
<MicVocal size={22} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-col">
|
|
{album.map((t, i) => {
|
|
const cur = isCurrent(rel, t.file);
|
|
const artist = t.artist ?? t.albumArtist ?? '';
|
|
const dur = fmtDuration(t.durationSec);
|
|
return (
|
|
<div
|
|
key={t.file}
|
|
className={`group flex items-center gap-3 rounded px-3 py-2 ${
|
|
cur ? 'bg-primary/10 text-primary' : 'text-foreground hover:bg-muted'
|
|
}`}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() => playCurrent(i)}
|
|
className="flex min-w-0 flex-1 items-center gap-3 text-left"
|
|
>
|
|
<span className="flex w-5 shrink-0 justify-end">
|
|
{cur ? (
|
|
<Volume2 size={15} className="text-primary" />
|
|
) : (
|
|
<span className="text-sm tabular-nums text-muted-foreground">{i + 1}</span>
|
|
)}
|
|
</span>
|
|
<span className="min-w-0 flex-1">
|
|
<span className={`block truncate text-sm ${cur ? 'font-medium' : ''}`}>{t.title ?? t.file}</span>
|
|
{artist && <span className="block truncate text-xs text-muted-foreground">{artist}</span>}
|
|
</span>
|
|
{dur && <span className="shrink-0 text-xs tabular-nums text-muted-foreground">{dur}</span>}
|
|
</button>
|
|
<MusicHeart
|
|
kind="track"
|
|
favKey={trackHomePath(rel, t.file)}
|
|
size={16}
|
|
hoverReveal
|
|
className="shrink-0"
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Artist — discography sections */}
|
|
{disco && !album && (
|
|
<div className="flex flex-col gap-6">
|
|
<div className="flex items-center gap-3">
|
|
<h1 className="text-3xl font-bold text-foreground">{disco.artist}</h1>
|
|
<MusicHeart kind="artist" favKey={rel} size={22} className="p-1" />
|
|
</div>
|
|
{TYPE_ORDER.filter((type) => folders.some((f) => (disco.albums[f] ?? 'Other') === type)).map((type) => (
|
|
<section key={type}>
|
|
<h2 className="mb-2 text-lg font-semibold text-foreground">
|
|
{type === 'Studio' ? 'Studio Albums' : type}
|
|
</h2>
|
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
|
{folders
|
|
.filter((f) => (disco.albums[f] ?? 'Other') === type)
|
|
.map((f) => (
|
|
<Card key={f} r={childRel(f)} name={f} playable={(manifest[childRel(f)]?.tracks ?? 0) > 0} />
|
|
))}
|
|
</div>
|
|
</section>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* 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 && (
|
|
<div className="flex flex-col gap-6">
|
|
{crumbs.length >= 2 && (
|
|
<div className="flex items-center gap-3">
|
|
<h1 className="text-3xl font-bold text-foreground">{crumbs[crumbs.length - 1]}</h1>
|
|
<MusicHeart kind="artist" favKey={rel} size={22} className="p-1" />
|
|
</div>
|
|
)}
|
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
|
{folders.map((f) => (
|
|
<Card key={f} r={childRel(f)} name={f} playable={(manifest[childRel(f)]?.tracks ?? 0) > 0} />
|
|
))}
|
|
{!folders.length && <p className="text-sm text-muted-foreground">Empty</p>}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
// 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 = (
|
|
<div className="flex h-full flex-col">
|
|
{content}
|
|
<MusicMiniBar />
|
|
{/* The audio engine, mounted HERE rather than by the shell.
|
|
It moved out of DashboardLayout on 2026-08-15 when the player became the plugin's. It renders
|
|
nothing while the route is /music — the mini bar above is the transport — so this is purely
|
|
"something owns the GaplessEngine while the screen is open".
|
|
[phase 2] Leaving /music unmounts it, which stops playback. Making audio outlive the route
|
|
needs either a shell slot a plugin can contribute to, or the engine hoisted to module scope;
|
|
that decision is deliberately deferred. Nothing breaks in the meantime: player-time's
|
|
registrations are optional-chained and the queue lives in global state, so returning to /music
|
|
remounts the host and reloads it. */}
|
|
<MusicPlayerHost />
|
|
</div>
|
|
);
|
|
|
|
if (!lyricsOpen) return libraryView;
|
|
|
|
return (
|
|
<LibraryViewContext.Provider value={libraryView}>
|
|
<WorkspaceLayout layout={LYRICS_LAYOUT} onLayoutChange={keepLayout} components={LYRICS_PANELS} noHeader />
|
|
</LibraryViewContext.Provider>
|
|
);
|
|
};
|