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.
216 lines
7.5 KiB
TypeScript
216 lines
7.5 KiB
TypeScript
import { useState, type ReactNode } from 'react';
|
|
import { Link, useNavigate } from 'react-router';
|
|
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 './useMusicPlayer';
|
|
import { MusicHeart } from './MusicHeart';
|
|
import { useMusicFavorites } from './useMusicFavorites';
|
|
import {
|
|
MUSIC_FAV_CHANNEL,
|
|
coverUrl,
|
|
musicPath,
|
|
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"). An album/artist row is a link into the library; a track row plays, so it stays a
|
|
// button — it mutates rather than navigates, even though it also moves the library to the album.
|
|
export const FavoritesView = () => {
|
|
const { get, token } = useClient();
|
|
const navigate = useNavigate();
|
|
const player = useMusicPlayer();
|
|
const { favorites } = useMusicFavorites();
|
|
const [, setFavOpen] = usePanelChannel<boolean>(MUSIC_FAV_CHANNEL, 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<AlbumMeta>(`/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);
|
|
}
|
|
navigate(musicPath(albumRel));
|
|
setFavOpen(false);
|
|
};
|
|
|
|
const empty = !favorites.artists.length && !favorites.albums.length && !favorites.tracks.length;
|
|
|
|
return (
|
|
<div className="h-full overflow-y-auto p-4 md:p-6">
|
|
<div className="mb-4 flex items-center gap-2">
|
|
<Heart size={20} className="fill-red-500 text-red-500" />
|
|
<h1 className="flex-1 text-2xl font-bold text-foreground">Favorites</h1>
|
|
<button
|
|
type="button"
|
|
onClick={() => setFavOpen(false)}
|
|
className="cursor-pointer rounded p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground"
|
|
>
|
|
<X size={18} />
|
|
</button>
|
|
</div>
|
|
|
|
{empty ? (
|
|
<div className="flex flex-col items-center justify-center gap-3 py-24 text-center">
|
|
<Heart size={44} className="text-muted-foreground/30" />
|
|
<p className="text-sm text-muted-foreground">
|
|
No favorites yet. Click the heart on any artist, album or track.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col gap-6">
|
|
<Section title="Artists" count={favorites.artists.length}>
|
|
{favorites.artists.map((key) => {
|
|
const segs = key.split('/');
|
|
return (
|
|
<FavRow
|
|
key={key}
|
|
kind="artist"
|
|
favKey={key}
|
|
cover={coverUrl(key, token)}
|
|
fallback={<User size={18} className="text-muted-foreground" />}
|
|
title={segs[segs.length - 1] ?? key}
|
|
to={musicPath(key)}
|
|
onClick={() => setFavOpen(false)}
|
|
chevron
|
|
/>
|
|
);
|
|
})}
|
|
</Section>
|
|
|
|
<Section title="Albums" count={favorites.albums.length}>
|
|
{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 (
|
|
<FavRow
|
|
key={key}
|
|
kind="album"
|
|
favKey={key}
|
|
cover={coverUrl(key, token)}
|
|
fallback={<Disc3 size={18} className="text-muted-foreground" />}
|
|
title={title}
|
|
subtitle={[artist, year].filter(Boolean).join(' · ')}
|
|
to={musicPath(key)}
|
|
onClick={() => setFavOpen(false)}
|
|
chevron
|
|
/>
|
|
);
|
|
})}
|
|
</Section>
|
|
|
|
<Section title="Tracks" count={favorites.tracks.length}>
|
|
{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 (
|
|
<FavRow
|
|
key={key}
|
|
kind="track"
|
|
favKey={key}
|
|
cover={coverUrl(albumRel, token)}
|
|
fallback={<Music size={18} className="text-muted-foreground" />}
|
|
title={base.replace(/\.[^/.]+$/, '')}
|
|
subtitle={album}
|
|
onClick={() => playTrack(key)}
|
|
/>
|
|
);
|
|
})}
|
|
</Section>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const Section = ({ title, count, children }: { title: string; count: number; children: ReactNode }) =>
|
|
count ? (
|
|
<div>
|
|
<h2 className="mb-1 px-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
|
{title} <span className="text-muted-foreground/60">{count}</span>
|
|
</h2>
|
|
<div className="flex flex-col">{children}</div>
|
|
</div>
|
|
) : null;
|
|
|
|
// `to` makes the row an anchor (an album or artist, which is a place); without it the row is a button
|
|
// (a track, which plays). The heart stays a sibling either way — it must not be inside either one.
|
|
const FavRow = ({
|
|
kind,
|
|
favKey,
|
|
cover,
|
|
fallback,
|
|
title,
|
|
subtitle,
|
|
to,
|
|
onClick,
|
|
chevron,
|
|
}: {
|
|
kind: FavoriteKind;
|
|
favKey: string;
|
|
cover: string;
|
|
fallback: ReactNode;
|
|
title: string;
|
|
subtitle?: string;
|
|
to?: string;
|
|
onClick: () => void;
|
|
chevron?: boolean;
|
|
}) => {
|
|
const [failed, setFailed] = useState(false);
|
|
const inner = (
|
|
<>
|
|
<div className="flex h-11 w-11 shrink-0 items-center justify-center overflow-hidden rounded bg-muted">
|
|
{cover && !failed ? (
|
|
<img src={cover} alt="" className="h-full w-full object-cover" onError={() => setFailed(true)} />
|
|
) : (
|
|
fallback
|
|
)}
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<span className="block truncate text-sm text-foreground">{title}</span>
|
|
{subtitle ? <span className="block truncate text-xs text-muted-foreground">{subtitle}</span> : null}
|
|
</div>
|
|
</>
|
|
);
|
|
const cls = 'flex min-w-0 flex-1 cursor-pointer items-center gap-3 text-left';
|
|
return (
|
|
<div className="group flex items-center gap-3 rounded-md px-2 py-1.5 hover:bg-muted">
|
|
{to ? (
|
|
<Link to={to} onClick={onClick} className={cls}>
|
|
{inner}
|
|
</Link>
|
|
) : (
|
|
<button type="button" onClick={onClick} className={cls}>
|
|
{inner}
|
|
</button>
|
|
)}
|
|
<MusicHeart kind={kind} favKey={favKey} size={16} className="shrink-0" />
|
|
{chevron ? <ChevronRight size={16} className="shrink-0 text-muted-foreground" /> : null}
|
|
</div>
|
|
);
|
|
};
|