music, extracted from the platform into its own repository
Everything the plugin is, moved out of officerdev/platform on 2026-08-15 — 41 files, unchanged from the tree they left. manifest.ts identity, one permission, ffmpeg/ffprobe declared api/ the sidecar proxy; the prefix comes from mountPrefix() sidecar/ the whole /api/music contract — indexing, streaming, per-user state db/ music_favorites, _playlists, _playlist_items, _now_playing web/ panels, layout, and the player: engine, bar, lyrics, favourites cliamp/ the second playback path, parked — not working, kept deliberately widgets/ the dashboard widget, parked — plugins cannot contribute widgets assets/ icon.png, the dock tile scripts/ the reindex CLI PLUGIN.md is the design record: what moved, what stayed, what broke, and why. MUSIC_API.md is the contract the phone and tablet apps speak, and the reason the sidecar's HTTP shape is not free to change. ── It does not build here, and that is the point ── The platform resolves `hooks/useClient`, `officerdev`, `officerdb/db` and `@@/*` through the workspace links in its own node_modules. Measured from this directory, outside the platform checkout, every one of them fails to resolve — 7 imports in the backend, ~29 in the frontend. So this repository is the source of truth, not yet a buildable unit. Making it one means the host API becoming something a plugin can depend on rather than something it reaches into. That is the next problem, and having the code here is what makes it unavoidable rather than theoretical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { Loader2, Music4 } from 'lucide-react';
|
||||
import type { LyricLine } from './lyrics';
|
||||
import { seekPlayer } from './player-time';
|
||||
import { useActiveLyricIndex } from './useLyrics';
|
||||
|
||||
type LyricsPaneProps = {
|
||||
lines: LyricLine[] | null;
|
||||
synced: boolean;
|
||||
loading: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* The lyrics sheet. Synced (.lrc) lyrics centre, highlight the current line, auto-scroll and seek on
|
||||
* click; plain (.txt) lyrics left-align and scroll by hand only.
|
||||
*
|
||||
* It takes no position prop: it subscribes to the player clock itself and only re-renders when the
|
||||
* highlight moves, so the sixty-frames-a-second feed never reaches the DOM.
|
||||
*/
|
||||
export const LyricsPane = ({ lines, synced, loading }: LyricsPaneProps) => {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const lineRefs = useRef<(HTMLParagraphElement | null)[]>([]);
|
||||
const activeIndex = useActiveLyricIndex(lines, synced);
|
||||
|
||||
// Keep the active line ~40% down the panel. scrollTop rather than scrollIntoView, which would also
|
||||
// scroll every ancestor and drag the whole workspace.
|
||||
useEffect(() => {
|
||||
if (!synced || activeIndex < 0) return;
|
||||
const box = scrollRef.current;
|
||||
const el = lineRefs.current[activeIndex];
|
||||
if (!box || !el) return;
|
||||
box.scrollTo({ top: Math.max(0, el.offsetTop - box.clientHeight * 0.4), behavior: 'smooth' });
|
||||
}, [activeIndex, synced]);
|
||||
|
||||
const rendered = useMemo(() => {
|
||||
if (!lines) return null;
|
||||
return lines.map((line, i) => {
|
||||
const active = synced && i === activeIndex;
|
||||
const seekable = synced && line.timeSec != null;
|
||||
return (
|
||||
<p
|
||||
key={i}
|
||||
ref={(el) => {
|
||||
lineRefs.current[i] = el;
|
||||
}}
|
||||
onClick={seekable ? () => seekPlayer(line.timeSec!) : undefined}
|
||||
className={[
|
||||
'py-1 text-[15px] font-semibold leading-7 transition-colors duration-200',
|
||||
synced ? 'text-center' : 'text-left text-foreground/85',
|
||||
// Only the colour changes on the active line — no weight or size change, so nothing reflows
|
||||
// and the sheet does not jitter as the highlight moves.
|
||||
active ? 'text-foreground' : synced ? 'text-muted-foreground' : '',
|
||||
seekable ? 'cursor-pointer hover:text-foreground/80' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
{line.text || (synced ? '♪' : ' ')}
|
||||
</p>
|
||||
);
|
||||
});
|
||||
}, [lines, synced, activeIndex]);
|
||||
|
||||
const empty = !loading && (!lines || !lines.length);
|
||||
|
||||
return (
|
||||
<div ref={scrollRef} className="h-full overflow-y-auto px-6 pt-3">
|
||||
{loading && (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
{empty && (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||
<Music4 size={28} className="opacity-40" />
|
||||
<p className="text-sm">No lyrics for this track.</p>
|
||||
</div>
|
||||
)}
|
||||
{!loading && <div className="mx-auto max-w-2xl">{rendered}</div>}
|
||||
{/* Tail so the last lines can still scroll up to the 40% mark. */}
|
||||
{!loading && synced && <div style={{ height: '55%' }} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { MicVocal } from 'lucide-react';
|
||||
import { LyricsPane } from './LyricsPane';
|
||||
import { useLyrics } from './useLyrics';
|
||||
import { useLyricsOpen } from './useLyricsOpen';
|
||||
import { useMusicPlayer } from './useMusicPlayer';
|
||||
|
||||
/**
|
||||
* The right-hand half of the /music detail panel when lyrics are on. It follows the PLAYING track, not
|
||||
* the browsed album — which is why it reads the player rather than taking props from the album view.
|
||||
*/
|
||||
export const LyricsPanel = () => {
|
||||
const { token } = useClient();
|
||||
const { current } = useMusicPlayer();
|
||||
const [, toggleLyrics] = useLyricsOpen();
|
||||
const lyrics = useLyrics(current?.albumRel ?? '', current?.file ?? '', true, token);
|
||||
|
||||
// `dark` is not decoration: the theme tokens are CSS variables scoped to a `.dark` ancestor, so marking
|
||||
// this subtree re-points foreground/muted-foreground/border at their dark values. Without it a light
|
||||
// theme would paint near-black text on the black sheet.
|
||||
return (
|
||||
<div className="dark flex h-full flex-col bg-black">
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-border px-4 py-2">
|
||||
<MicVocal size={15} className="shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-foreground">{current?.title ?? current?.file ?? 'Lyrics'}</p>
|
||||
{current?.artist && <p className="truncate text-xs text-muted-foreground">{current.artist}</p>}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleLyrics}
|
||||
title="Hide lyrics"
|
||||
className="shrink-0 cursor-pointer text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1">
|
||||
{current ? (
|
||||
<LyricsPane lines={lyrics.lines} synced={lyrics.synced} loading={lyrics.loading} />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center px-6 text-center text-sm text-muted-foreground">
|
||||
Play something to see its lyrics.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,226 @@
|
||||
import { useState, useEffect, type ReactNode } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import { Library, Music2, ChevronLeft, Folder, Search, X, RefreshCw, Heart } from 'lucide-react';
|
||||
import {
|
||||
MUSIC_ROOT,
|
||||
MUSIC_FAV_CHANNEL,
|
||||
MUSIC_RESYNC_CHANNEL,
|
||||
coverUrl,
|
||||
fuzzyMatch,
|
||||
musicParentPath,
|
||||
musicPath,
|
||||
toRel,
|
||||
useMusicCwd,
|
||||
type LsResult,
|
||||
type Manifest,
|
||||
type ManifestAlbum,
|
||||
} from './shared';
|
||||
|
||||
// A row's leading thumbnail: the folder's indexed cover (its folder.jpg/cover.jpg, server-compressed),
|
||||
// falling back to an icon when it has none or the image fails to load.
|
||||
const RowThumb = ({ src, fallback }: { src: string | null; fallback: ReactNode }) => {
|
||||
const [failed, setFailed] = useState(false);
|
||||
useEffect(() => setFailed(false), [src]);
|
||||
return (
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded bg-muted">
|
||||
{src && !failed ? (
|
||||
<img src={src} alt="" className="h-full w-full object-cover" onError={() => setFailed(true)} />
|
||||
) : (
|
||||
fallback
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Hidden files/folders (dotfiles like .claude, .git) never belong in the library listing.
|
||||
const visibleDirs = (r: LsResult) =>
|
||||
r.entries
|
||||
.filter((e) => e.type === 'directory' && !e.name.startsWith('.'))
|
||||
.map((e) => e.name)
|
||||
.sort();
|
||||
|
||||
// Left panel of the /music workspace — a single-column drill-down LIST navigator (libraries →
|
||||
// artists → albums as list items; never a grid). Every row is a link to `/music?path=…`; MusicDetail
|
||||
// (right panel) reads the same param and renders the rich detail (covers/grids/tracklist).
|
||||
export const MusicBrowser = () => {
|
||||
const { get, post, token } = useClient();
|
||||
const cwd = useMusicCwd();
|
||||
const [favOpen, setFavOpen] = usePanelChannel<boolean>(MUSIC_FAV_CHANNEL, false);
|
||||
const [resync, setResync] = usePanelChannel<number>(MUSIC_RESYNC_CHANNEL, 0);
|
||||
const [manifest, setManifest] = useState<Record<string, ManifestAlbum>>({});
|
||||
const [libraries, setLibraries] = useState<string[]>([]);
|
||||
const [folders, setFolders] = useState<string[]>([]);
|
||||
const [query, setQuery] = useState('');
|
||||
const [reindexing, setReindexing] = useState(false);
|
||||
|
||||
// Refetch manifest + libraries on mount and whenever a reindex bumps the resync nonce.
|
||||
useEffect(() => {
|
||||
get<Manifest>('/music/manifest')
|
||||
.then((m) => setManifest(m.albums))
|
||||
.catch(() => setManifest({}));
|
||||
get<LsResult>(`/file-browser/ls?path=${encodeURIComponent(MUSIC_ROOT)}`)
|
||||
.then((r) => setLibraries(visibleDirs(r)))
|
||||
.catch(() => setLibraries([]));
|
||||
}, [resync]);
|
||||
|
||||
// The container folder whose children we list = the current folder, or its parent when the current
|
||||
// path is an album leaf (so its siblings stay listed while the right shows the tracklist).
|
||||
const rel = toRel(cwd);
|
||||
const isAlbum = (manifest[rel]?.tracks ?? 0) > 0;
|
||||
const navFolder = !cwd ? null : isAlbum ? cwd.split('/').slice(0, -1).join('/') : cwd;
|
||||
const selected = cwd ? cwd.split('/').pop() : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!navFolder) {
|
||||
setFolders([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
get<LsResult>(`/file-browser/ls?path=${encodeURIComponent(navFolder)}`)
|
||||
.then((r) => {
|
||||
if (!cancelled) setFolders(visibleDirs(r));
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [navFolder, resync]);
|
||||
|
||||
// Start each folder unfiltered.
|
||||
useEffect(() => setQuery(''), [navFolder]);
|
||||
|
||||
// Trigger a server-side library rebuild, then bump the resync nonce so BOTH panels refetch their
|
||||
// manifest / listings / meta (a fresh Date.now() value guarantees the effects re-run).
|
||||
const reindex = async () => {
|
||||
if (reindexing) return;
|
||||
setReindexing(true);
|
||||
try {
|
||||
await post('/music/reindex');
|
||||
setResync(Date.now());
|
||||
} finally {
|
||||
setReindexing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const crumbs = navFolder ? navFolder.slice(MUSIC_ROOT.length + 1).split('/') : [];
|
||||
const coverFor = (childRel: string) => (manifest[childRel]?.cover ? coverUrl(childRel, token) : null);
|
||||
const shownLibraries = libraries.filter((l) => fuzzyMatch(query, l));
|
||||
const shownFolders = folders.filter((f) => fuzzyMatch(query, f));
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-y-auto p-3">
|
||||
<div className="flex items-center gap-1 pb-2">
|
||||
{/* Favorites is a view of this panel, not a location, so it stays a channel — but going home
|
||||
has to close it explicitly: the route doesn't change when you are already at the root. */}
|
||||
<Link
|
||||
to="/music"
|
||||
onClick={() => setFavOpen(false)}
|
||||
className="flex flex-1 cursor-pointer items-center gap-2 px-2 text-left text-foreground"
|
||||
>
|
||||
<Music2 size={20} className="text-primary" />
|
||||
<span className="text-lg font-semibold">Music</span>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFavOpen(!favOpen)}
|
||||
title="Favorites"
|
||||
className="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-md hover:bg-muted"
|
||||
>
|
||||
<Heart
|
||||
size={18}
|
||||
className={favOpen ? 'fill-red-500 text-red-500' : 'text-muted-foreground hover:text-foreground'}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-2 flex items-center gap-1.5 px-1">
|
||||
<div className="flex h-8 min-w-0 flex-1 items-center gap-1.5 rounded-md border border-border bg-background px-2">
|
||||
<Search size={13} className="shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Filter…"
|
||||
className="min-w-0 flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuery('')}
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={reindex}
|
||||
disabled={reindexing}
|
||||
title="Reindex library"
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-border text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw size={14} className={reindexing ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!navFolder ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2 px-2 pb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<Library size={13} /> Libraries
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{shownLibraries.map((lib) => (
|
||||
<Link
|
||||
key={lib}
|
||||
to={musicPath(lib)}
|
||||
className="flex items-center gap-3 truncate rounded-md px-2 py-2 text-left text-base text-muted-foreground hover:bg-muted/60 hover:text-foreground"
|
||||
>
|
||||
<RowThumb src={coverFor(lib)} fallback={<Library size={18} className="text-muted-foreground" />} />
|
||||
<span className="truncate">{lib}</span>
|
||||
</Link>
|
||||
))}
|
||||
{!shownLibraries.length && (
|
||||
<span className="px-2 text-base text-muted-foreground">{query ? 'No matches' : 'No libraries'}</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link
|
||||
to={musicParentPath(toRel(navFolder))}
|
||||
className="mb-1 flex items-center gap-1 truncate px-2 py-1 text-left text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ChevronLeft size={13} className="shrink-0" />
|
||||
<span className="truncate">{crumbs.join(' / ')}</span>
|
||||
</Link>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{shownFolders.map((f) => (
|
||||
<Link
|
||||
key={f}
|
||||
to={musicPath(toRel(`${navFolder}/${f}`))}
|
||||
className={`flex items-center gap-3 truncate rounded-md px-2 py-2 text-left text-base ${
|
||||
selected === f
|
||||
? 'bg-muted text-foreground'
|
||||
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<RowThumb
|
||||
src={coverFor(toRel(`${navFolder}/${f}`))}
|
||||
fallback={<Folder size={18} className="text-muted-foreground" />}
|
||||
/>
|
||||
<span className="truncate">{f}</span>
|
||||
</Link>
|
||||
))}
|
||||
{!shownFolders.length && (
|
||||
<span className="px-2 py-2 text-base text-muted-foreground">
|
||||
{query ? 'No matches' : 'No subfolders'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,438 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Heart } from 'lucide-react';
|
||||
import type { FavoriteKind } from './shared';
|
||||
import { useMusicFavorites } from './useMusicFavorites';
|
||||
|
||||
/**
|
||||
* A heart toggle for a favoritable thing (track / album / artist). Reads and writes the shared
|
||||
* favorites cache, so every heart for the same key stays in sync and flips optimistically. Stops click
|
||||
* propagation so it works inside clickable rows/cards. Renders nothing without a key.
|
||||
*/
|
||||
export const MusicHeart = ({
|
||||
kind,
|
||||
favKey,
|
||||
size = 18,
|
||||
className = '',
|
||||
hoverReveal = false,
|
||||
}: {
|
||||
kind: FavoriteKind;
|
||||
favKey: string;
|
||||
size?: number;
|
||||
className?: string;
|
||||
/** When set, a NOT-favorited heart is hidden until the enclosing `group` is hovered/focused; a
|
||||
* favorited (filled) heart always stays visible. Keeps dense lists uncluttered. */
|
||||
hoverReveal?: boolean;
|
||||
}) => {
|
||||
const { isFavorite, toggle } = useMusicFavorites();
|
||||
if (!favKey) return null;
|
||||
const on = isFavorite(kind, favKey);
|
||||
const reveal = hoverReveal && !on ? 'opacity-0 group-hover:opacity-100 focus:opacity-100' : '';
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggle(kind, favKey);
|
||||
}}
|
||||
title={on ? 'Remove from favorites' : 'Add to favorites'}
|
||||
aria-label={on ? 'Remove from favorites' : 'Add to favorites'}
|
||||
className={`flex cursor-pointer items-center justify-center transition-colors ${reveal} ${className}`}
|
||||
>
|
||||
<Heart size={size} className={on ? 'fill-red-500 text-red-500' : 'text-muted-foreground hover:text-foreground'} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useRef } from 'react';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { MicVocal, Pause, Play } from 'lucide-react';
|
||||
import { SeekBar } from 'officerdev';
|
||||
import { coverUrl, fmtClock } from './shared';
|
||||
import { seekPlayer } from './player-time';
|
||||
import { useLyricsOpen } from './useLyricsOpen';
|
||||
import { useMusicPlayer } from './useMusicPlayer';
|
||||
import { usePlayerClock } from './usePlayerClock';
|
||||
|
||||
/**
|
||||
* The player, reduced to what the /music screen does not already show. The album view has the transport
|
||||
* and the tracklist, so this is the scrubber — plus play/pause and the lyrics toggle, which are the two
|
||||
* controls you can still want while browsing an album that ISN'T the one playing.
|
||||
*
|
||||
* It sits inside the detail panel, which is why the full dock hides on /music: two bars would be one bar
|
||||
* too many, and the dock's own row costs the workspace its height on every screen.
|
||||
*/
|
||||
export const MusicMiniBar = () => {
|
||||
const { token } = useClient();
|
||||
const { current, playing, toggle } = useMusicPlayer();
|
||||
const [lyricsOpen, toggleLyrics] = useLyricsOpen();
|
||||
const { position, duration } = usePlayerClock();
|
||||
const barRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
if (!current) return null;
|
||||
|
||||
const onSeekDown = (ev: React.MouseEvent<HTMLDivElement>) => {
|
||||
const seekAt = (clientX: number) => {
|
||||
const bar = barRef.current;
|
||||
if (!bar || !duration) return;
|
||||
const rect = bar.getBoundingClientRect();
|
||||
seekPlayer(Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)) * duration);
|
||||
};
|
||||
ev.preventDefault();
|
||||
seekAt(ev.clientX);
|
||||
const onMove = (moveEv: MouseEvent) => seekAt(moveEv.clientX);
|
||||
const onUp = () => {
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-3 border-t border-border bg-card/60 px-4 py-2">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded bg-muted">
|
||||
<img
|
||||
src={coverUrl(current.albumRel, token)}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
onError={(ev) => {
|
||||
(ev.currentTarget as HTMLImageElement).style.visibility = 'hidden';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
title={playing ? 'Pause' : 'Play'}
|
||||
className="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-full bg-primary text-primary-foreground hover:opacity-90 active:scale-95"
|
||||
>
|
||||
{playing ? <Pause size={15} /> : <Play size={15} className="ml-0.5" />}
|
||||
</button>
|
||||
|
||||
<div className="hidden w-48 min-w-0 shrink-0 sm:block">
|
||||
<p className="truncate text-xs font-medium text-foreground">{current.title ?? current.file}</p>
|
||||
{current.artist && <p className="truncate text-[11px] text-muted-foreground">{current.artist}</p>}
|
||||
</div>
|
||||
|
||||
<span className="w-10 shrink-0 text-right font-mono text-[10px] tabular-nums text-muted-foreground">
|
||||
{fmtClock(position)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<SeekBar
|
||||
barRef={barRef}
|
||||
onSeekDown={onSeekDown}
|
||||
pct={duration ? (position / duration) * 100 : 0}
|
||||
trackClass="bg-muted"
|
||||
fillClass="bg-primary"
|
||||
thumbClass="border-background"
|
||||
/>
|
||||
</div>
|
||||
<span className="w-10 shrink-0 font-mono text-[10px] tabular-nums text-muted-foreground">
|
||||
{fmtClock(duration)}
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleLyrics}
|
||||
title={lyricsOpen ? 'Hide lyrics' : 'Show lyrics'}
|
||||
aria-pressed={lyricsOpen}
|
||||
className={`shrink-0 cursor-pointer p-1 hover:text-foreground ${
|
||||
lyricsOpen ? 'text-primary' : 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
<MicVocal size={16} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,393 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { usePermissions } from 'hooks/usePermissions';
|
||||
import { Play, Pause, SkipBack, SkipForward, X, Volume2, VolumeX, Loader2, MicVocal } from 'lucide-react';
|
||||
import { SeekBar } from 'officerdev';
|
||||
import { MusicHeart } from './MusicHeart';
|
||||
import { fmtClock, musicPath, sortTracks, trackHomePath, type AlbumMeta, type NowPlaying } from './shared';
|
||||
import { useMusicPlayer, type PlayerTrack } from './useMusicPlayer';
|
||||
import { GaplessEngine, type EngineTrack } from './gapless-engine';
|
||||
import { publishPlayerTime, registerPlayerSeek } from './player-time';
|
||||
import { useLyricsOpen } from './useLyricsOpen';
|
||||
|
||||
// Mounted once in the persistent DashboardLayout (outside <Routes>), so it owns the single audio engine
|
||||
// and the site-wide play dock — playback survives navigation between routes.
|
||||
//
|
||||
// Audio is Web Audio (gapless-engine), NOT an <audio> element: it schedules each track to start at the
|
||||
// exact sample the previous one ends, so a continuous mix plays with zero gap on auto-advance. React
|
||||
// holds the queue/index (shared via useMusicPlayer); this host reconciles it with the engine — user
|
||||
// actions (new album, jump, prev/next) command the engine, and the engine's own natural advance mirrors
|
||||
// back into the index without restarting playback.
|
||||
|
||||
const MUSIC_API = '/api/music';
|
||||
|
||||
export const MusicPlayerHost = () => {
|
||||
const { token, get, put, delete: del } = useClient();
|
||||
const { can } = usePermissions();
|
||||
const canUseMusic = can('music');
|
||||
const navigate = useNavigate();
|
||||
const { pathname } = useLocation();
|
||||
const { current, index, queue, playing, toggle, next, prev, setPlaying, syncIndex, close, loadQueue } =
|
||||
useMusicPlayer();
|
||||
|
||||
const engineRef = useRef<GaplessEngine | null>(null);
|
||||
const [position, setPosition] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [volume, setVolume] = useState(() => {
|
||||
const v = parseFloat(localStorage.getItem('music.volume') ?? '1');
|
||||
return Number.isFinite(v) ? v : 1;
|
||||
});
|
||||
const [muted, setMuted] = useState(false);
|
||||
const [lyricsOpen, toggleLyrics] = useLyricsOpen();
|
||||
|
||||
const trackKey = current ? `${current.albumRel}/${current.file}` : '';
|
||||
|
||||
// Latest position/duration in refs so persist reads fresh values without re-subscribing. seekToRef holds
|
||||
// a pending restore offset; isRestoringRef suppresses persist during the restore load so it doesn't
|
||||
// clobber the saved position with 0; restoredRef makes restore run once; engineIndexRef is the index the
|
||||
// engine is actually on, used to tell an engine-driven advance apart from a user jump.
|
||||
const positionRef = useRef(0);
|
||||
positionRef.current = position;
|
||||
const durationRef = useRef(0);
|
||||
durationRef.current = duration;
|
||||
const restoredRef = useRef(false);
|
||||
const isRestoringRef = useRef(false);
|
||||
const seekToRef = useRef<number | null>(null);
|
||||
const engineIndexRef = useRef(0);
|
||||
// The engine is created once, so its callbacks would capture first-render closures. useMusicPlayer's
|
||||
// functional setters (syncIndex/setPlaying) read the state captured at THAT render (the initial EMPTY
|
||||
// queue) — calling them from a stale closure wipes the queue. Route them through refs kept current.
|
||||
const syncIndexRef = useRef(syncIndex);
|
||||
syncIndexRef.current = syncIndex;
|
||||
const setPlayingRef = useRef(setPlaying);
|
||||
setPlayingRef.current = setPlaying;
|
||||
const closeRef = useRef(close);
|
||||
closeRef.current = close;
|
||||
|
||||
const withToken = (u: string) => (token ? `${u}${u.includes('?') ? '&' : '?'}token=${encodeURIComponent(token)}` : u);
|
||||
const streamUrl = (t: PlayerTrack) =>
|
||||
withToken(`${MUSIC_API}/stream?path=${encodeURIComponent(`Music/${t.albumRel}/${t.file}`)}`);
|
||||
const coverUrl = (rel: string) => withToken(`${MUSIC_API}/cover?path=${encodeURIComponent(rel)}`);
|
||||
const toEngineTrack = (t: PlayerTrack): EngineTrack => ({ key: `${t.albumRel}/${t.file}`, url: streamUrl(t) });
|
||||
|
||||
const persist = () => {
|
||||
if (!current) return;
|
||||
put('/music/now-playing?device=web', {
|
||||
homePath: trackHomePath(current.albumRel, current.file),
|
||||
dir: `Music/${current.albumRel}`,
|
||||
title: current.title ?? '',
|
||||
artist: current.artist ?? '',
|
||||
album: current.albumRel.split('/').pop() ?? '',
|
||||
durationSec: Math.round(durationRef.current) || 0,
|
||||
positionSec: Math.round(positionRef.current) || 0,
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
// ── Engine lifecycle (mounted once) ──
|
||||
useEffect(() => {
|
||||
const engine = new GaplessEngine({
|
||||
onTime: (pos, dur) => {
|
||||
positionRef.current = pos;
|
||||
durationRef.current = dur;
|
||||
setPosition(pos);
|
||||
setDuration(dur);
|
||||
publishPlayerTime(pos, dur); // the lyrics pane and the /music scrubber live in another tree
|
||||
isRestoringRef.current = false; // saved position has been applied — safe to persist again
|
||||
},
|
||||
onIndex: (i) => {
|
||||
engineIndexRef.current = i; // engine advanced on its own → mirror to UI without restarting
|
||||
syncIndexRef.current(i);
|
||||
},
|
||||
// Queue finished on its own → clear it so the in-flow dock releases its space (no idle bar lingering
|
||||
// after playback). The saved snapshot is left intact, so a reload still resumes where you left off.
|
||||
onEndOfQueue: () => closeRef.current(),
|
||||
onLoadingChange: setLoading,
|
||||
});
|
||||
engineRef.current = engine;
|
||||
engine.setVolume(muted ? 0 : volume);
|
||||
// Satisfy the browser autoplay policy ONCE, on the first user gesture — after that, sticky activation
|
||||
// lets engine.play() resume the context on its own. It MUST be once-only: a persistent listener would
|
||||
// resume the context on every click, overriding a deliberate pause (pause = ctx.suspend()).
|
||||
const unlock = () => engine.unlock();
|
||||
document.addEventListener('pointerdown', unlock, { once: true });
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', unlock);
|
||||
engine.destroy();
|
||||
engineRef.current = null;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Restore the saved "currently playing" on first load — paused, at its position — so a reload/return
|
||||
// lands back on the track. Skipped when a queue already exists (an in-app nav kept player state).
|
||||
//
|
||||
// Also skipped without the `music` permission. This host is mounted by the shell for every account, so it
|
||||
// used to reach for `/music/now-playing` on a member's very first paint and 403.
|
||||
useEffect(() => {
|
||||
if (restoredRef.current) return;
|
||||
if (!canUseMusic) return;
|
||||
restoredRef.current = true;
|
||||
if (queue.length) return;
|
||||
(async () => {
|
||||
const snap = await get<NowPlaying | null>('/music/now-playing?device=web').catch(() => null);
|
||||
if (!snap?.homePath) return;
|
||||
const albumRel = (snap.dir || snap.homePath.split('/').slice(0, -1).join('/')).replace(/^Music\//, '');
|
||||
const file = snap.homePath.split('/').pop() ?? '';
|
||||
const meta = await get<AlbumMeta>(`/music/meta?path=${encodeURIComponent(albumRel)}`).catch(() => null);
|
||||
if (!meta) return;
|
||||
const q: PlayerTrack[] = sortTracks(meta.tracks).map((t) => ({
|
||||
albumRel,
|
||||
file: t.file,
|
||||
title: t.title,
|
||||
artist: t.artist,
|
||||
}));
|
||||
const idx = Math.max(
|
||||
0,
|
||||
q.findIndex((t) => t.file === file),
|
||||
);
|
||||
isRestoringRef.current = true;
|
||||
seekToRef.current = snap.positionSec > 0 ? snap.positionSec : null;
|
||||
loadQueue(q, idx);
|
||||
})();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// A NEW queue (playQueue/loadQueue set a fresh array) → (re)load the engine at that index. A pending
|
||||
// restore offset starts it paused at position; otherwise autoplay follows the queue's `playing` flag.
|
||||
useEffect(() => {
|
||||
const engine = engineRef.current;
|
||||
if (!engine) return;
|
||||
engineIndexRef.current = index;
|
||||
const seekTo = seekToRef.current ?? 0;
|
||||
seekToRef.current = null;
|
||||
engine.load(queue.map(toEngineTrack), index, playing, seekTo);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [queue]);
|
||||
|
||||
// Index changed on the SAME queue: a user jump / prev-next (engine advance already matches, so it no-ops).
|
||||
useEffect(() => {
|
||||
const engine = engineRef.current;
|
||||
if (!engine || !queue.length) return;
|
||||
if (index === engineIndexRef.current) return;
|
||||
engineIndexRef.current = index;
|
||||
engine.skipTo(index);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [index]);
|
||||
|
||||
// Play/pause.
|
||||
useEffect(() => {
|
||||
const engine = engineRef.current;
|
||||
if (!engine || !current) return;
|
||||
if (playing) engine.play();
|
||||
else engine.pause();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [playing]);
|
||||
|
||||
// Volume / mute.
|
||||
useEffect(() => {
|
||||
engineRef.current?.setVolume(muted ? 0 : volume);
|
||||
}, [volume, muted]);
|
||||
|
||||
// Persist on play/pause + track change (not during the restore load).
|
||||
useEffect(() => {
|
||||
if (isRestoringRef.current) return;
|
||||
persist();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [trackKey, playing]);
|
||||
|
||||
// Heartbeat while playing, so the saved position keeps up.
|
||||
useEffect(() => {
|
||||
if (!playing || !current) return;
|
||||
const id = setInterval(persist, 10000);
|
||||
return () => clearInterval(id);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [playing, trackKey]);
|
||||
|
||||
const changeVolume = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const v = parseFloat(e.target.value);
|
||||
setVolume(v);
|
||||
setMuted(v === 0);
|
||||
localStorage.setItem('music.volume', String(v));
|
||||
};
|
||||
|
||||
// Seek is the engine's, and the engine is this component's — so the lyrics panel, which lives in the
|
||||
// /music workspace rather than under the dock, reaches it through this registration.
|
||||
const seekTo = useCallback((sec: number) => {
|
||||
setPosition(sec);
|
||||
publishPlayerTime(sec, durationRef.current);
|
||||
engineRef.current?.seek(sec);
|
||||
}, []);
|
||||
|
||||
useEffect(() => registerPlayerSeek(seekTo), [seekTo]);
|
||||
|
||||
// Scrubber → engine.seek (Web Audio has no <audio>.currentTime, so drive it directly).
|
||||
const barRef = useRef<HTMLDivElement>(null);
|
||||
const onSeekDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const seekAt = (clientX: number) => {
|
||||
const bar = barRef.current;
|
||||
if (!bar || !duration) return;
|
||||
const rect = bar.getBoundingClientRect();
|
||||
const pct = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
||||
seekTo(pct * duration);
|
||||
};
|
||||
e.preventDefault();
|
||||
seekAt(e.clientX);
|
||||
const onMove = (ev: MouseEvent) => seekAt(ev.clientX);
|
||||
const onUp = () => {
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
};
|
||||
|
||||
const subtitle = current?.artist ?? current?.albumRel.split('/').slice(-2, -1)[0] ?? '';
|
||||
const pct = duration ? (position / duration) * 100 : 0;
|
||||
|
||||
// Closing the dock also clears the saved "currently playing" snapshot, so it doesn't get restored on
|
||||
// the next load. (Merely close()-ing the local queue would leave the server snapshot to bring it back.)
|
||||
const handleClose = () => {
|
||||
del('/music/now-playing?device=web').catch(() => {});
|
||||
close();
|
||||
};
|
||||
|
||||
// The dock's microphone opens the lyrics panel inside the /music workspace, so it navigates there
|
||||
// rather than growing a sheet of its own — the dock keeps its height on every screen.
|
||||
const showLyrics = () => {
|
||||
if (!lyricsOpen) navigate('/music');
|
||||
toggleLyrics();
|
||||
};
|
||||
|
||||
if (!current) return null;
|
||||
|
||||
// On /music the library screen draws its own MusicMiniBar inside the panel, and the album view already
|
||||
// has the transport — so the dock would be a second bar taking a full row off the workspace. The host
|
||||
// stays MOUNTED (it owns the engine); only its bar is withheld.
|
||||
if (pathname.startsWith('/music')) return null;
|
||||
|
||||
// In-flow bottom bar (NOT position:fixed) — it reserves its own height so the content above shrinks to
|
||||
// fit and the nav dock naturally sits above it, no overlap hacks needed.
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-3 border-t border-border bg-card/95 px-4 py-2 backdrop-blur">
|
||||
{/* cover + info — a real link to the playing album, so it cmd-clicks like anything else */}
|
||||
<Link
|
||||
to={musicPath(current.albumRel)}
|
||||
title="Show in library"
|
||||
className="flex min-w-0 cursor-pointer items-center gap-3 rounded text-left transition-opacity hover:opacity-80"
|
||||
>
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center overflow-hidden rounded bg-muted">
|
||||
<img
|
||||
src={coverUrl(current.albumRel)}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
onError={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.visibility = 'hidden';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="hidden w-44 shrink-0 sm:block">
|
||||
<p className="truncate text-sm font-medium text-foreground">{current.title ?? current.file}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">{subtitle}</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* transport */}
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={prev}
|
||||
disabled={index === 0}
|
||||
className="cursor-pointer p-1.5 text-muted-foreground hover:text-foreground disabled:cursor-default disabled:opacity-40"
|
||||
>
|
||||
<SkipBack size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-full bg-primary text-primary-foreground hover:opacity-90 active:scale-95"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
) : playing ? (
|
||||
<Pause size={18} />
|
||||
) : (
|
||||
<Play size={18} className="ml-0.5" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={next}
|
||||
disabled={index >= queue.length - 1}
|
||||
className="cursor-pointer p-1.5 text-muted-foreground hover:text-foreground disabled:cursor-default disabled:opacity-40"
|
||||
>
|
||||
<SkipForward size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* scrubber + times */}
|
||||
<span className="hidden w-10 shrink-0 text-right font-mono text-[10px] tabular-nums text-muted-foreground md:block">
|
||||
{fmtClock(position)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<SeekBar
|
||||
barRef={barRef}
|
||||
onSeekDown={onSeekDown}
|
||||
pct={pct}
|
||||
trackClass="bg-muted"
|
||||
fillClass="bg-primary"
|
||||
thumbClass="border-background"
|
||||
/>
|
||||
</div>
|
||||
<span className="hidden w-10 shrink-0 font-mono text-[10px] tabular-nums text-muted-foreground md:block">
|
||||
{fmtClock(duration)}
|
||||
</span>
|
||||
|
||||
{/* volume */}
|
||||
<div className="hidden shrink-0 items-center gap-1.5 md:flex">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMuted((m) => !m)}
|
||||
className="cursor-pointer text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{muted || volume === 0 ? <VolumeX size={16} /> : <Volume2 size={16} />}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={muted ? 0 : volume}
|
||||
onChange={changeVolume}
|
||||
className="h-1 w-16 cursor-pointer accent-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={showLyrics}
|
||||
title={lyricsOpen ? 'Hide lyrics' : 'Show lyrics'}
|
||||
aria-pressed={lyricsOpen}
|
||||
className={`shrink-0 cursor-pointer p-1.5 hover:text-foreground ${lyricsOpen ? 'text-primary' : 'text-muted-foreground'}`}
|
||||
>
|
||||
<MicVocal size={18} />
|
||||
</button>
|
||||
|
||||
<MusicHeart
|
||||
kind="track"
|
||||
favKey={trackHomePath(current.albumRel, current.file)}
|
||||
size={18}
|
||||
className="shrink-0 p-1.5"
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="shrink-0 cursor-pointer p-1.5 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,191 @@
|
||||
import { describe, expect, test, beforeEach, afterEach } from 'bun:test';
|
||||
import { GaplessEngine, type EngineTrack } from './gapless-engine';
|
||||
|
||||
// These tests exist because the failure they cover is inaudible to the code and obvious to the ear: two
|
||||
// sources playing the same track at once. Nothing throws, no state looks wrong, `cur` points at a real
|
||||
// node that is really playing — there is simply a second one nobody is holding. The only way to catch it
|
||||
// is to count the nodes that were started.
|
||||
|
||||
type StartCall = { when: number; offset: number };
|
||||
|
||||
class FakeSource {
|
||||
buffer: AudioBuffer | null = null;
|
||||
onended: (() => void) | null = null;
|
||||
started: StartCall | null = null;
|
||||
stopped = false;
|
||||
disconnected = false;
|
||||
constructor(private ctx: FakeContext) {}
|
||||
connect(): void {}
|
||||
disconnect(): void {
|
||||
this.disconnected = true;
|
||||
}
|
||||
start(when = 0, offset = 0): void {
|
||||
if (this.started) throw new Error('InvalidStateError: already started');
|
||||
this.started = { when, offset };
|
||||
this.ctx.started.push(this);
|
||||
}
|
||||
stop(): void {
|
||||
if (!this.started) throw new Error('InvalidStateError: not started');
|
||||
this.stopped = true;
|
||||
}
|
||||
/** What the browser does at the end of the buffer (or at stop()) — the engine's advance trigger. */
|
||||
end(): void {
|
||||
this.onended?.();
|
||||
}
|
||||
}
|
||||
|
||||
class FakeContext {
|
||||
currentTime = 0;
|
||||
state: 'running' | 'suspended' | 'closed' = 'running';
|
||||
destination = {};
|
||||
started: FakeSource[] = [];
|
||||
createGain() {
|
||||
return { gain: { value: 1 }, connect: () => {} };
|
||||
}
|
||||
createBufferSource() {
|
||||
return new FakeSource(this) as unknown as AudioBufferSourceNode & FakeSource;
|
||||
}
|
||||
async resume(): Promise<void> {
|
||||
this.state = 'running';
|
||||
}
|
||||
async suspend(): Promise<void> {
|
||||
this.state = 'suspended';
|
||||
}
|
||||
async close(): Promise<void> {
|
||||
this.state = 'closed';
|
||||
}
|
||||
async decodeAudioData(): Promise<AudioBuffer> {
|
||||
// Decoding is the slow step the bug hides inside — a macrotask is enough to model "not instant".
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
return { duration: 100 } as AudioBuffer;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sources producing sound RIGHT NOW. The `when <= currentTime` clause is not a detail: a gapless engine
|
||||
* always has the next track already started, scheduled at the current track's end. Counting those as
|
||||
* audible would make the healthy state look like the bug.
|
||||
*/
|
||||
const audible = (ctx: FakeContext) =>
|
||||
ctx.started.filter((s) => !s.stopped && s.started !== null && s.started.when <= ctx.currentTime);
|
||||
|
||||
let ctx: FakeContext;
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalCtor = (globalThis as Record<string, unknown>).AudioContext;
|
||||
|
||||
const QUEUE: EngineTrack[] = [
|
||||
{ key: 'a', url: '/stream/a' },
|
||||
{ key: 'b', url: '/stream/b' },
|
||||
{ key: 'c', url: '/stream/c' },
|
||||
];
|
||||
|
||||
// Let every pending decode + its continuation run.
|
||||
const settle = async () => {
|
||||
for (let i = 0; i < 8; i++) await new Promise((r) => setTimeout(r, 0));
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
ctx = new FakeContext();
|
||||
(globalThis as Record<string, unknown>).AudioContext = function () {
|
||||
return ctx;
|
||||
};
|
||||
globalThis.fetch = (async () => ({
|
||||
ok: true,
|
||||
arrayBuffer: async () => new ArrayBuffer(8),
|
||||
})) as unknown as typeof fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
(globalThis as Record<string, unknown>).AudioContext = originalCtor;
|
||||
});
|
||||
|
||||
describe('GaplessEngine', () => {
|
||||
test('load(autoplay) followed by play() starts the track exactly once', async () => {
|
||||
// The host's queue effect and playing effect both fire in the same commit when a queue starts from a
|
||||
// paused player. This is that commit, and it used to produce two sources of the same track.
|
||||
const engine = new GaplessEngine({});
|
||||
engine.load(QUEUE, 0, true);
|
||||
engine.play();
|
||||
await settle();
|
||||
|
||||
expect(audible(ctx)).toHaveLength(1);
|
||||
engine.destroy();
|
||||
});
|
||||
|
||||
test('repeated play() during the decode does not stack sources', async () => {
|
||||
const engine = new GaplessEngine({});
|
||||
engine.load(QUEUE, 0, true);
|
||||
engine.play();
|
||||
engine.play();
|
||||
engine.play();
|
||||
await settle();
|
||||
|
||||
expect(audible(ctx)).toHaveLength(1);
|
||||
engine.destroy();
|
||||
});
|
||||
|
||||
test('pause then play while still decoding does not stack sources', async () => {
|
||||
// The impatient-user path: nothing is audible yet because the file is still downloading, so the play
|
||||
// button gets hit again.
|
||||
const engine = new GaplessEngine({});
|
||||
engine.load(QUEUE, 0, true);
|
||||
engine.pause();
|
||||
engine.play();
|
||||
await settle();
|
||||
|
||||
expect(audible(ctx)).toHaveLength(1);
|
||||
engine.destroy();
|
||||
});
|
||||
|
||||
test('a stale source ending does not advance the queue', async () => {
|
||||
// Defence in depth: even if a source outlives its bookkeeping, only `cur` may move the index.
|
||||
const seen: number[] = [];
|
||||
const engine = new GaplessEngine({ onIndex: (i) => seen.push(i) });
|
||||
engine.load(QUEUE, 0, true);
|
||||
await settle();
|
||||
|
||||
const first = audible(ctx)[0]!;
|
||||
engine.skipTo(2); // bumps the generation and stops `first`
|
||||
await settle();
|
||||
first.end(); // the browser still delivers its onended
|
||||
|
||||
expect(seen).toEqual([]); // skipTo is a user action; only a NATURAL boundary reports an index
|
||||
engine.destroy();
|
||||
});
|
||||
|
||||
test('switching queues leaves nothing from the old one sounding', async () => {
|
||||
const engine = new GaplessEngine({});
|
||||
engine.load(QUEUE, 0, true);
|
||||
await settle();
|
||||
engine.load([{ key: 'z', url: '/stream/z' }], 0, true);
|
||||
await settle();
|
||||
|
||||
expect(audible(ctx)).toHaveLength(1);
|
||||
expect(audible(ctx)[0]!.started).not.toBeNull();
|
||||
engine.destroy();
|
||||
});
|
||||
|
||||
test('a natural boundary advances the index exactly once', async () => {
|
||||
const seen: number[] = [];
|
||||
const engine = new GaplessEngine({ onIndex: (i) => seen.push(i) });
|
||||
engine.load(QUEUE, 0, true);
|
||||
await settle();
|
||||
|
||||
const cur = audible(ctx)[0]!;
|
||||
cur.end();
|
||||
await settle();
|
||||
|
||||
expect(seen).toEqual([1]);
|
||||
engine.destroy();
|
||||
});
|
||||
|
||||
test('destroy() silences every source it started', async () => {
|
||||
const engine = new GaplessEngine({});
|
||||
engine.load(QUEUE, 0, true);
|
||||
await settle();
|
||||
engine.destroy();
|
||||
|
||||
expect(audible(ctx)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,375 @@
|
||||
// Sample-accurate gapless playback engine (Web Audio).
|
||||
//
|
||||
// The HTML5 <audio> element cannot play tracks back-to-back without a gap: advancing swaps the element's
|
||||
// `src`, which re-fetches + re-buffers the next file. For a continuous DJ mix (already silence-trimmed at
|
||||
// the edges) that gap is audible. This engine instead decodes each track to an AudioBuffer and schedules
|
||||
// the NEXT track's AudioBufferSourceNode to `start()` at the precise AudioContext time the current track
|
||||
// ends — so the seam is sample-accurate, zero gap.
|
||||
//
|
||||
// Cost of that guarantee: it must download + decode the WHOLE next file to PCM ahead of time (a ~10-min
|
||||
// track ≈ 200 MB in memory), so we cache only a few decoded buffers. Startup / manual-skip therefore has
|
||||
// a decode latency (surfaced via onLoadingChange); auto-advance is pre-decoded so it's instant.
|
||||
|
||||
export type EngineTrack = { key: string; url: string };
|
||||
|
||||
export type EngineCallbacks = {
|
||||
/** The engine advanced to a new track on its OWN (natural boundary) — mirror it into UI state. */
|
||||
onIndex?: (index: number) => void;
|
||||
/** Current position + duration (seconds). Fires ~per animation frame while playing, and on load/seek. */
|
||||
onTime?: (positionSec: number, durationSec: number) => void;
|
||||
/** Playback stopped because the queue ran out (host should reflect paused state). */
|
||||
onEndOfQueue?: () => void;
|
||||
/** True while the CURRENT track is being fetched/decoded (startup / skip / seek). */
|
||||
onLoadingChange?: (loading: boolean) => void;
|
||||
};
|
||||
|
||||
const CACHE_MAX = 3; // decoded buffers kept (current + next + a little headroom); bounds memory
|
||||
|
||||
export class GaplessEngine {
|
||||
private ctx: AudioContext | null = null;
|
||||
private gain: GainNode | null = null;
|
||||
private volume = 1;
|
||||
|
||||
private buffers = new Map<string, AudioBuffer>(); // url → decoded PCM (insertion-ordered LRU)
|
||||
private inflight = new Map<string, Promise<AudioBuffer | null>>();
|
||||
|
||||
private queue: EngineTrack[] = [];
|
||||
private index = 0;
|
||||
|
||||
private cur: AudioBufferSourceNode | null = null;
|
||||
private nxt: AudioBufferSourceNode | null = null;
|
||||
// EVERY source node this engine has started and that has not yet ended. `cur`/`nxt` are the two it is
|
||||
// reasoning about; this is the set it is responsible for silencing. They diverged once — a duplicate
|
||||
// begin() left a source playing that nothing held a reference to, so nothing could ever stop it — and
|
||||
// an orphan in Web Audio is unstoppable and inaudible to the code. Add here, stop from here.
|
||||
private live = new Set<AudioBufferSourceNode>();
|
||||
private curBaseTime = 0; // ctx time that corresponds to position 0 of the current track
|
||||
private curDuration = 0;
|
||||
private nextStartAt = 0; // ctx time the scheduled `nxt` will begin (= current track's end)
|
||||
private pendingOffset = 0; // where the current track should (re)start from — seek/resume offset
|
||||
private started = false; // has the current track's source actually been started?
|
||||
private playing = false;
|
||||
private gen = 0; // bumped on any disruptive change; stale async/onended callbacks check it and bail
|
||||
// The generation a begin() is currently in flight for, or -1. `gen` alone cannot express this: it marks
|
||||
// a change, and two begin() calls for the SAME generation are exactly the case that must be refused.
|
||||
private beginGen = -1;
|
||||
private raf = 0;
|
||||
|
||||
private cb: EngineCallbacks;
|
||||
|
||||
constructor(cb: EngineCallbacks) {
|
||||
this.cb = cb;
|
||||
}
|
||||
|
||||
private ensureCtx(): AudioContext {
|
||||
if (!this.ctx) {
|
||||
this.ctx = new AudioContext();
|
||||
this.gain = this.ctx.createGain();
|
||||
this.gain.gain.value = this.volume;
|
||||
this.gain.connect(this.ctx.destination);
|
||||
}
|
||||
return this.ctx;
|
||||
}
|
||||
|
||||
/** Resume the context from a user gesture (autoplay policy). Safe to call repeatedly. */
|
||||
unlock(): void {
|
||||
const ctx = this.ensureCtx();
|
||||
if (ctx.state === 'suspended') void ctx.resume();
|
||||
}
|
||||
|
||||
setVolume(v: number): void {
|
||||
this.volume = v;
|
||||
if (this.gain) this.gain.gain.value = v;
|
||||
}
|
||||
|
||||
// ── Decoding (fetch full file → PCM), deduped + LRU-capped ──
|
||||
private decode(url: string): Promise<AudioBuffer | null> {
|
||||
const cached = this.buffers.get(url);
|
||||
if (cached) return Promise.resolve(cached);
|
||||
const existing = this.inflight.get(url);
|
||||
if (existing) return existing;
|
||||
const ctx = this.ensureCtx();
|
||||
const p = (async () => {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) return null;
|
||||
const bytes = await res.arrayBuffer();
|
||||
const decoded = await ctx.decodeAudioData(bytes);
|
||||
this.buffers.set(url, decoded);
|
||||
this.evict();
|
||||
return decoded;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
this.inflight.delete(url);
|
||||
}
|
||||
})();
|
||||
this.inflight.set(url, p);
|
||||
return p;
|
||||
}
|
||||
|
||||
private evict(): void {
|
||||
while (this.buffers.size > CACHE_MAX) {
|
||||
const keep = new Set([this.queue[this.index]?.url, this.queue[this.index + 1]?.url]);
|
||||
let removed = false;
|
||||
for (const k of this.buffers.keys()) {
|
||||
if (!keep.has(k)) {
|
||||
this.buffers.delete(k);
|
||||
removed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!removed) break; // everything left is current/next — stop
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public control surface ──
|
||||
|
||||
/** Load a queue and either start it (autoplay) or prepare it paused at `seekTo`. */
|
||||
load(queue: EngineTrack[], index: number, autoplay: boolean, seekTo = 0): void {
|
||||
this.gen++;
|
||||
this.stopSources();
|
||||
this.queue = queue;
|
||||
this.index = queue.length ? Math.max(0, Math.min(index, queue.length - 1)) : 0;
|
||||
this.playing = autoplay && queue.length > 0;
|
||||
this.started = false;
|
||||
this.pendingOffset = Math.max(0, seekTo);
|
||||
this.curDuration = 0;
|
||||
this.curBaseTime = 0;
|
||||
this.startTicker();
|
||||
if (!queue.length) return;
|
||||
if (autoplay) void this.begin(this.gen);
|
||||
else void this.prepare(this.gen);
|
||||
}
|
||||
|
||||
/** Manual jump to an arbitrary index (prev / next button / track click). A small decode hitch is fine. */
|
||||
skipTo(index: number): void {
|
||||
this.gen++;
|
||||
this.stopSources();
|
||||
this.index = Math.max(0, Math.min(index, this.queue.length - 1));
|
||||
this.pendingOffset = 0;
|
||||
this.started = false;
|
||||
this.curDuration = 0;
|
||||
if (this.playing) void this.begin(this.gen);
|
||||
else void this.prepare(this.gen);
|
||||
}
|
||||
|
||||
seek(sec: number): void {
|
||||
if (!this.queue[this.index]) return;
|
||||
this.gen++;
|
||||
this.stopSources();
|
||||
this.pendingOffset = Math.max(0, this.curDuration ? Math.min(sec, this.curDuration) : sec);
|
||||
this.started = false;
|
||||
if (this.playing) {
|
||||
void this.begin(this.gen);
|
||||
} else {
|
||||
this.cb.onTime?.(this.pendingOffset, this.curDuration);
|
||||
void this.prepare(this.gen);
|
||||
}
|
||||
}
|
||||
|
||||
play(): void {
|
||||
this.playing = true;
|
||||
const ctx = this.ensureCtx();
|
||||
if (!this.started) void this.begin(this.gen);
|
||||
else if (ctx.state === 'suspended') void ctx.resume();
|
||||
}
|
||||
|
||||
pause(): void {
|
||||
this.playing = false;
|
||||
if (this.ctx && this.ctx.state === 'running') void this.ctx.suspend();
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.gen++;
|
||||
if (this.raf) cancelAnimationFrame(this.raf);
|
||||
this.raf = 0;
|
||||
this.stopSources();
|
||||
this.buffers.clear();
|
||||
this.inflight.clear();
|
||||
if (this.ctx) {
|
||||
void this.ctx.close();
|
||||
this.ctx = null;
|
||||
this.gain = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internals ──
|
||||
|
||||
/** Decode the current track without starting it — paused/prepared (restore, or seek while paused). */
|
||||
private async prepare(gen: number): Promise<void> {
|
||||
const t = this.queue[this.index];
|
||||
if (!t) return;
|
||||
this.cb.onLoadingChange?.(true);
|
||||
const buf = await this.decode(t.url);
|
||||
this.cb.onLoadingChange?.(false);
|
||||
if (gen !== this.gen || !buf) return;
|
||||
this.curDuration = buf.duration;
|
||||
this.cb.onTime?.(Math.min(this.pendingOffset, buf.duration), buf.duration);
|
||||
void this.preloadNext(gen);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the current track at `pendingOffset` (decoding first if needed).
|
||||
*
|
||||
* Re-entrant calls for the same generation are REFUSED, and that guard is the whole reason this method
|
||||
* is not simply idempotent-by-gen. `started` only becomes true at the very end, after a fetch and a
|
||||
* full decode — seconds, for a long track. Anything that calls begin() in that window sees
|
||||
* `started === false` and starts a second, parallel decode of the same track, and both finish and both
|
||||
* call startBuffer(): two sources of the same audio playing at once, only one of them in `cur`.
|
||||
*
|
||||
* That was not a rare race. Starting a queue from a paused player fired it every single time: the host
|
||||
* commits a new queue and `playing: true` together, its queue effect calls load(autoplay) → begin, and
|
||||
* its playing effect then calls play() → begin again, same generation. The audible result compounds —
|
||||
* the untracked twin keeps its own onended, so at the track boundary advance() runs twice, the index
|
||||
* jumps two tracks and a second source is promoted while the first is still sounding.
|
||||
*/
|
||||
private async begin(gen: number): Promise<void> {
|
||||
if (this.beginGen === gen) return;
|
||||
this.beginGen = gen;
|
||||
try {
|
||||
const ctx = this.ensureCtx();
|
||||
if (ctx.state === 'suspended') await ctx.resume();
|
||||
if (gen !== this.gen) return;
|
||||
const t = this.queue[this.index];
|
||||
if (!t) return;
|
||||
this.cb.onLoadingChange?.(true);
|
||||
const buf = await this.decode(t.url);
|
||||
this.cb.onLoadingChange?.(false);
|
||||
if (gen !== this.gen || !buf) return;
|
||||
this.startBuffer(buf, this.pendingOffset, gen);
|
||||
void this.preloadNext(gen);
|
||||
} finally {
|
||||
// Released even on the bail paths: a decode that fails must not leave this generation permanently
|
||||
// unable to start, or a failed track would wedge the player until something bumped `gen`.
|
||||
if (this.beginGen === gen) this.beginGen = -1;
|
||||
}
|
||||
}
|
||||
|
||||
private startBuffer(buf: AudioBuffer, offset: number, gen: number): void {
|
||||
const ctx = this.ensureCtx();
|
||||
const src = ctx.createBufferSource();
|
||||
src.buffer = buf;
|
||||
src.connect(this.gain!);
|
||||
const startAt = ctx.currentTime;
|
||||
const off = Math.min(Math.max(0, offset), buf.duration);
|
||||
src.start(startAt, off);
|
||||
this.live.add(src);
|
||||
src.onended = () => this.onSourceEnded(src, gen);
|
||||
this.cur = src;
|
||||
this.curDuration = buf.duration;
|
||||
this.curBaseTime = startAt - off; // position = ctx.currentTime - curBaseTime
|
||||
this.started = true;
|
||||
this.playing = true;
|
||||
}
|
||||
|
||||
/** Decode index+1 and schedule it to begin exactly when the current track ends. */
|
||||
private async preloadNext(gen: number): Promise<void> {
|
||||
if (this.nxt) {
|
||||
const stale = this.nxt;
|
||||
this.live.delete(stale);
|
||||
try {
|
||||
stale.onended = null;
|
||||
stale.stop();
|
||||
} catch {
|
||||
/* not started */
|
||||
}
|
||||
try {
|
||||
stale.disconnect();
|
||||
} catch {
|
||||
/* already disconnected */
|
||||
}
|
||||
this.nxt = null;
|
||||
}
|
||||
const forIndex = this.index;
|
||||
const nextTrack = this.queue[forIndex + 1];
|
||||
if (!nextTrack || !this.started) return;
|
||||
const buf = await this.decode(nextTrack.url);
|
||||
if (gen !== this.gen || this.index !== forIndex || !buf) return;
|
||||
const ctx = this.ensureCtx();
|
||||
const boundary = this.curBaseTime + this.curDuration;
|
||||
if (boundary <= ctx.currentTime) return; // already past — advance() will start it fresh
|
||||
const src = ctx.createBufferSource();
|
||||
src.buffer = buf;
|
||||
src.connect(this.gain!);
|
||||
src.start(boundary, 0);
|
||||
this.live.add(src);
|
||||
src.onended = () => this.onSourceEnded(src, gen);
|
||||
this.nxt = src;
|
||||
this.nextStartAt = boundary;
|
||||
}
|
||||
|
||||
/**
|
||||
* A source finished. Only the one the engine considers CURRENT may drive the queue forward.
|
||||
*
|
||||
* Without the identity check, any source that outlives its bookkeeping still advances the queue when it
|
||||
* ends — so one stray node does not just play unwanted audio, it desynchronises the index for
|
||||
* everything after it. `cur` is the single source of truth for "what is playing"; ending anything else
|
||||
* is bookkeeping, not an event.
|
||||
*/
|
||||
private onSourceEnded(src: AudioBufferSourceNode, gen: number): void {
|
||||
this.live.delete(src);
|
||||
if (gen !== this.gen || this.cur !== src) return;
|
||||
this.advance();
|
||||
}
|
||||
|
||||
/** Fired at a track boundary (its source ended): move to the next track. */
|
||||
private advance(): void {
|
||||
if (this.index + 1 >= this.queue.length) {
|
||||
this.playing = false;
|
||||
this.started = false;
|
||||
this.cur = null;
|
||||
this.cb.onEndOfQueue?.();
|
||||
return;
|
||||
}
|
||||
this.index++;
|
||||
const promoted = this.nxt;
|
||||
this.nxt = null;
|
||||
if (promoted?.buffer) {
|
||||
// The next source was scheduled to start at the exact boundary — it's already playing seamlessly.
|
||||
this.cur = promoted;
|
||||
this.curDuration = promoted.buffer.duration;
|
||||
this.curBaseTime = this.nextStartAt;
|
||||
this.started = true;
|
||||
this.cb.onIndex?.(this.index);
|
||||
void this.preloadNext(this.gen);
|
||||
} else {
|
||||
// Next wasn't decoded in time (rare) — start it now, accepting a tiny gap this once.
|
||||
this.cb.onIndex?.(this.index);
|
||||
this.pendingOffset = 0;
|
||||
this.started = false;
|
||||
void this.begin(this.gen);
|
||||
}
|
||||
}
|
||||
|
||||
/** Silence everything this engine has started. Iterates `live`, not just cur/nxt — see its declaration. */
|
||||
private stopSources(): void {
|
||||
for (const s of this.live) {
|
||||
try {
|
||||
s.onended = null;
|
||||
s.stop();
|
||||
} catch {
|
||||
/* not started */
|
||||
}
|
||||
try {
|
||||
s.disconnect();
|
||||
} catch {
|
||||
/* already disconnected */
|
||||
}
|
||||
}
|
||||
this.live.clear();
|
||||
this.cur = null;
|
||||
this.nxt = null;
|
||||
}
|
||||
|
||||
private startTicker(): void {
|
||||
if (this.raf) return;
|
||||
const tick = () => {
|
||||
this.raf = requestAnimationFrame(tick);
|
||||
if (!this.ctx || !this.started || !this.playing) return;
|
||||
const pos = this.ctx.currentTime - this.curBaseTime;
|
||||
this.cb.onTime?.(Math.max(0, Math.min(pos, this.curDuration)), this.curDuration);
|
||||
};
|
||||
this.raf = requestAnimationFrame(tick);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
|
||||
export const defaultLayout: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'music-root',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'music-browser', appType: 'music-browser' }, size: 26 },
|
||||
{ node: { type: 'panel', id: 'music-detail', appType: 'music-detail' }, size: 74 },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { parseLyrics, activeLineIndex } from './lyrics';
|
||||
|
||||
describe('parseLyrics', () => {
|
||||
test('plain text is not synced and keeps every line, blanks included', () => {
|
||||
const { synced, lines } = parseLyrics('first\n\n second \n');
|
||||
expect(synced).toBe(false);
|
||||
expect(lines.map((l) => l.text)).toEqual(['first', '', 'second', '']);
|
||||
expect(lines.every((l) => l.timeSec === undefined)).toBe(true);
|
||||
});
|
||||
|
||||
test('lrc timestamps parse to seconds, with hundredths', () => {
|
||||
const { synced, lines } = parseLyrics('[00:12.50]hello\n[01:03]world');
|
||||
expect(synced).toBe(true);
|
||||
expect(lines).toEqual([
|
||||
{ timeSec: 12.5, text: 'hello' },
|
||||
{ timeSec: 63, text: 'world' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('a single-digit fraction is tenths, not thousandths', () => {
|
||||
expect(parseLyrics('[00:01.5]x').lines[0]?.timeSec).toBe(1.5);
|
||||
});
|
||||
|
||||
test('metadata tags are dropped', () => {
|
||||
const { lines } = parseLyrics('[ar:Artist]\n[ti:Title]\n[00:01.00]real');
|
||||
expect(lines).toEqual([{ timeSec: 1, text: 'real' }]);
|
||||
});
|
||||
|
||||
test('several stamps on one line become several lines, sorted by time', () => {
|
||||
const { lines } = parseLyrics('[02:00.00][00:30.00]chorus\n[01:00.00]verse');
|
||||
expect(lines).toEqual([
|
||||
{ timeSec: 30, text: 'chorus' },
|
||||
{ timeSec: 60, text: 'verse' },
|
||||
{ timeSec: 120, text: 'chorus' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('an untimed line inside a synced file survives, but blanks do not', () => {
|
||||
const { lines } = parseLyrics('[00:01.00]a\n\nspoken\n');
|
||||
expect(lines.map((l) => l.text)).toEqual(['spoken', 'a']);
|
||||
});
|
||||
|
||||
test('an empty timed line is kept — it is a musical rest', () => {
|
||||
expect(parseLyrics('[00:10.00]').lines).toEqual([{ timeSec: 10, text: '' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('activeLineIndex', () => {
|
||||
const lines = [
|
||||
{ timeSec: 10, text: 'a' },
|
||||
{ timeSec: 20, text: 'b' },
|
||||
{ timeSec: 30, text: 'c' },
|
||||
];
|
||||
|
||||
test('-1 before the first line', () => {
|
||||
expect(activeLineIndex(lines, 0)).toBe(-1);
|
||||
});
|
||||
|
||||
test('the 0.2s lookahead highlights fractionally early', () => {
|
||||
expect(activeLineIndex(lines, 9.7)).toBe(-1);
|
||||
expect(activeLineIndex(lines, 9.9)).toBe(0);
|
||||
});
|
||||
|
||||
test('holds the last line past the end', () => {
|
||||
expect(activeLineIndex(lines, 25)).toBe(1);
|
||||
expect(activeLineIndex(lines, 9999)).toBe(2);
|
||||
});
|
||||
|
||||
test('untimed lines never become active', () => {
|
||||
expect(activeLineIndex([{ text: 'x' }, { timeSec: 5, text: 'y' }], 60)).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Parse lyrics text into displayable lines. `.lrc` carries `[mm:ss.xx]` timestamps (possibly several per
|
||||
* line, e.g. repeated choruses) and metadata tags ([ar:], [ti:], …) which are dropped. `.txt` is plain.
|
||||
* A synced result is sorted by time so the active-line lookup is a simple scan.
|
||||
*
|
||||
* Ported from the mobile app (packages/core/src/services/lyrics.ts) — same file format, same server,
|
||||
* so the two must agree on what a line is.
|
||||
*/
|
||||
export type LyricLine = { timeSec?: number; text: string };
|
||||
export type ParsedLyrics = { synced: boolean; lines: LyricLine[] };
|
||||
|
||||
const TIME_RE = /\[(\d{1,2}):(\d{2})(?:[.:](\d{1,3}))?\]/g;
|
||||
const META_RE = /^\[(ar|ti|al|by|offset|length|re|ve|au|la|id):/i;
|
||||
|
||||
/**
|
||||
* Lines are treated as synced whenever the text actually contains `[mm:ss]` timestamps — the server's
|
||||
* `X-Lyrics-Format` header is not trusted (it need not survive a proxy, and embedded lyrics carrying
|
||||
* timestamps should sync regardless of which file they came from). No timestamps → plain text.
|
||||
*/
|
||||
export function parseLyrics(text: string): ParsedLyrics {
|
||||
if (!/\[\d{1,2}:\d{2}/.test(text)) {
|
||||
return { synced: false, lines: text.split(/\r?\n/).map((t) => ({ text: t.trim() })) };
|
||||
}
|
||||
|
||||
const out: LyricLine[] = [];
|
||||
for (const rawLine of text.split(/\r?\n/)) {
|
||||
if (META_RE.test(rawLine.trim())) continue;
|
||||
const stamps: number[] = [];
|
||||
let m: RegExpExecArray | null;
|
||||
TIME_RE.lastIndex = 0;
|
||||
while ((m = TIME_RE.exec(rawLine)) !== null) {
|
||||
const min = Number(m[1]);
|
||||
const sec = Number(m[2]);
|
||||
// "[00:12.3]" is three tenths, not three milliseconds — pad right before reading as thousandths.
|
||||
const frac = m[3] ? Number(`${m[3]}00`.slice(0, 3)) / 1000 : 0;
|
||||
stamps.push(min * 60 + sec + frac);
|
||||
}
|
||||
const lyric = rawLine.replace(TIME_RE, '').trim();
|
||||
if (!stamps.length) {
|
||||
if (lyric) out.push({ text: lyric }); // a plain line inside an otherwise-synced file
|
||||
continue;
|
||||
}
|
||||
for (const t of stamps) out.push({ timeSec: t, text: lyric });
|
||||
}
|
||||
|
||||
const synced = out.some((l) => l.timeSec != null);
|
||||
if (synced) out.sort((a, b) => (a.timeSec ?? 0) - (b.timeSec ?? 0));
|
||||
return { synced, lines: out };
|
||||
}
|
||||
|
||||
/**
|
||||
* Index of the active line for a playback position (synced only); -1 before the first line. The 0.2s
|
||||
* lookahead lands the highlight fractionally early, which reads as on-time — arriving late reads as lag.
|
||||
*/
|
||||
export function activeLineIndex(lines: LyricLine[], positionSec: number): number {
|
||||
let idx = -1;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const t = lines[i]?.timeSec;
|
||||
if (t == null) continue;
|
||||
if (t <= positionSec + 0.2) idx = i;
|
||||
else break;
|
||||
}
|
||||
return idx;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { AppRegistryMeta } from 'officerdev';
|
||||
import { Music, ListMusic } from 'lucide-react';
|
||||
import { MusicBrowser } from './MusicBrowser';
|
||||
import { MusicDetail } from './MusicDetail';
|
||||
|
||||
// The panels this plugin contributes. The shell renders `WorkspaceView` around them, arranged by
|
||||
// `layout.ts` — a plugin never renders the screen.
|
||||
//
|
||||
// The two do not coordinate with each other: both read `?path=` off the URL, which is why there is no
|
||||
// channel between them and why a library location is linkable and cmd-clickable. `MusicDetail` opens a
|
||||
// NESTED workspace of its own for the lyrics split, which is a layout inside a panel rather than a second
|
||||
// screen.
|
||||
//
|
||||
// `availableOnPanel: false` keeps them off the generic panel picker: they belong to this plugin's screen.
|
||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
{ key: 'music-browser', name: 'Library', icon: ListMusic, component: MusicBrowser, availableOnPanel: false },
|
||||
{ key: 'music-detail', name: 'Music', icon: Music, component: MusicDetail, availableOnPanel: false },
|
||||
];
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Playback position, published outside React.
|
||||
*
|
||||
* The lyrics pane no longer lives in the player's subtree — it renders in a panel of the /music
|
||||
* workspace — so the position has to cross the tree. It cannot cross as state: the engine reports a new
|
||||
* position every animation frame, and a shared state channel would re-render every consumer 60× a
|
||||
* second. Instead the host pushes into this module and subscribers decide for themselves what is worth
|
||||
* a render (the lyrics pane only re-renders when the ACTIVE LINE changes, roughly once a line).
|
||||
*
|
||||
* Seek travels the other way for the same reason: the engine is the host's, but a click on a lyric line
|
||||
* has to reach it.
|
||||
*/
|
||||
let position = 0;
|
||||
let duration = 0;
|
||||
const subscribers = new Set<(sec: number, dur: number) => void>();
|
||||
let seekFn: ((sec: number) => void) | null = null;
|
||||
|
||||
export const publishPlayerTime = (sec: number, dur: number): void => {
|
||||
position = sec;
|
||||
duration = dur;
|
||||
for (const fn of subscribers) fn(sec, dur);
|
||||
};
|
||||
|
||||
/** Latest values, for a subscriber that mounts mid-track. */
|
||||
export const getPlayerTime = (): number => position;
|
||||
export const getPlayerDuration = (): number => duration;
|
||||
|
||||
export const subscribePlayerTime = (fn: (sec: number, dur: number) => void): (() => void) => {
|
||||
subscribers.add(fn);
|
||||
return () => {
|
||||
subscribers.delete(fn);
|
||||
};
|
||||
};
|
||||
|
||||
export const registerPlayerSeek = (fn: (sec: number) => void): (() => void) => {
|
||||
seekFn = fn;
|
||||
return () => {
|
||||
if (seekFn === fn) seekFn = null;
|
||||
};
|
||||
};
|
||||
|
||||
export const seekPlayer = (sec: number): void => seekFn?.(sec);
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
// Shared types/helpers for the /music workspace panels (MusicBrowser + MusicDetail), which coordinate
|
||||
// via the `?path=` search param and play through the app-wide useMusicPlayer.
|
||||
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
export const MUSIC_ROOT = 'Music';
|
||||
export const MUSIC_FAV_CHANNEL = 'music:favorites';
|
||||
// Bumped (to a fresh nonce) when a library reindex finishes, so BOTH panels re-run their manifest /
|
||||
// listing / meta fetches — otherwise only the panel that triggered the reindex refreshes.
|
||||
export const MUSIC_RESYNC_CHANNEL = 'music:resync';
|
||||
|
||||
// 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[] };
|
||||
export type ManifestAlbum = { v: string; cover: boolean; tracks: number; disco?: boolean };
|
||||
export type Manifest = { albums: Record<string, ManifestAlbum> };
|
||||
export type Track = {
|
||||
file: string;
|
||||
title?: string;
|
||||
artist?: string;
|
||||
albumArtist?: string;
|
||||
track?: string;
|
||||
durationSec?: number;
|
||||
};
|
||||
export type AlbumMeta = { path: string; cover?: string; tracks: Track[] };
|
||||
|
||||
/** Seconds → "m:ss" (or "h:mm:ss" once past an hour); '' when unknown. */
|
||||
export const fmtDuration = (sec?: number): string => {
|
||||
if (!sec || sec <= 0) return '';
|
||||
const s = Math.round(sec);
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
const ss = String(s % 60).padStart(2, '0');
|
||||
return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`;
|
||||
};
|
||||
|
||||
/** Seconds → "m:ss" for a running clock: unknown reads as 0:00, never blank, so it doesn't jitter. */
|
||||
export const fmtClock = (sec: number): string =>
|
||||
Number.isFinite(sec) && sec >= 0
|
||||
? `${Math.floor(sec / 60)}:${String(Math.floor(sec % 60)).padStart(2, '0')}`
|
||||
: '0:00';
|
||||
|
||||
/** Case-insensitive subsequence fuzzy match: every char of `query` appears in order within `text`. */
|
||||
export const fuzzyMatch = (query: string, text: string): boolean => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
const t = text.toLowerCase();
|
||||
let qi = 0;
|
||||
for (let ti = 0; ti < t.length && qi < q.length; ti++) if (t[ti] === q[qi]!) qi++;
|
||||
return qi === q.length;
|
||||
};
|
||||
|
||||
/** Parse a track-number tag ("7", "07", "7/14") to a number, or null when absent/unparseable. */
|
||||
const trackNo = (t: Track): number | null => {
|
||||
const raw = t.track?.split('/')[0]?.trim();
|
||||
if (!raw) return null;
|
||||
const n = parseInt(raw, 10);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Canonical album track order: by the `track` NUMBER, falling back to the tag title only for tracks
|
||||
* that have no number (numbered tracks always precede unnumbered ones; filename breaks a final tie).
|
||||
* meta.json is in ffprobe/readdir order (arbitrary), so every consumer must sort with this.
|
||||
*/
|
||||
export const sortTracks = <T extends Track>(tracks: T[]): T[] => {
|
||||
const key = (t: Track) => (t.title || t.file).toLowerCase();
|
||||
return [...tracks].sort((a, b) => {
|
||||
const na = trackNo(a);
|
||||
const nb = trackNo(b);
|
||||
if (na !== null && nb !== null) return na - nb || key(a).localeCompare(key(b));
|
||||
if (na !== null) return -1;
|
||||
if (nb !== null) return 1;
|
||||
return key(a).localeCompare(key(b));
|
||||
});
|
||||
};
|
||||
export type Discography = { artist: string; albums: Record<string, string> };
|
||||
|
||||
export type FavoriteKind = 'track' | 'album' | 'artist';
|
||||
export type GroupedFavorites = { tracks: string[]; albums: string[]; artists: string[] };
|
||||
|
||||
/** Per-user "currently playing" snapshot (GET/PUT /api/music/now-playing). */
|
||||
export type NowPlaying = {
|
||||
homePath: string;
|
||||
dir: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
durationSec: number;
|
||||
positionSec: number;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
/** homePath ("Music/<rel>/<file>") for a track — its favorite key + /stream path. */
|
||||
export const trackHomePath = (rel: string, file: string) => `${MUSIC_ROOT}/${rel}/${file}`;
|
||||
|
||||
const AUDIO_EXT = new Set(['mp3', 'flac', 'm4a', 'aac', 'ogg', 'opus', 'wav', 'wma', 'mp4']);
|
||||
export const isAudio = (n: string) => {
|
||||
const d = n.lastIndexOf('.');
|
||||
return d >= 0 && AUDIO_EXT.has(n.slice(d + 1).toLowerCase());
|
||||
};
|
||||
|
||||
// Section order for an artist's discography.
|
||||
export const TYPE_ORDER = [
|
||||
'Studio',
|
||||
'Live',
|
||||
'Compilation',
|
||||
'EP',
|
||||
'Single',
|
||||
'Soundtrack',
|
||||
'Remix',
|
||||
'DJ-Mix',
|
||||
'Demo',
|
||||
'Mixtape',
|
||||
'Bootleg',
|
||||
'Other',
|
||||
];
|
||||
|
||||
export const coverUrl = (rel: string, token: string | null) =>
|
||||
`/api/music/cover?path=${encodeURIComponent(rel)}${token ? `&token=${encodeURIComponent(token)}` : ''}`;
|
||||
|
||||
/** Path (home-relative) → rel (relative to the Music root). */
|
||||
export const toRel = (cwd: string | null) => (cwd ? cwd.slice(MUSIC_ROOT.length + 1) : '');
|
||||
|
||||
// Where you are in the library is `/music?path=<rel>`, not a `music:cwd` channel. A query param rather
|
||||
// than `/music/*` because the location is one of several things this screen holds (the lyrics split and
|
||||
// the favorites view are the others), and because a splat would have to be the last segment of the
|
||||
// route — the same reason /chat spells its group that way. `rel === ''` is the library root, which is
|
||||
// the bare /music and a real state, so there is no redirect guard.
|
||||
export const MUSIC_PATH_PARAM = 'path';
|
||||
|
||||
/** Link target for a library location. `rel` is relative to the Music root; '' is the root itself. */
|
||||
export const musicPath = (rel: string) => (rel ? `/music?${MUSIC_PATH_PARAM}=${encodeURIComponent(rel)}` : '/music');
|
||||
|
||||
/** Link target for the parent of `rel` — '' (the root) is its own parent, which is where "up" stops. */
|
||||
export const musicParentPath = (rel: string) => musicPath(rel.split('/').slice(0, -1).join('/'));
|
||||
|
||||
/**
|
||||
* The open library folder as a home-relative path ("Music/…"), or null at the root — the vocabulary the
|
||||
* panels already speak, so reading the URL costs them nothing. Each panel calls this itself; they never
|
||||
* tell each other where they are.
|
||||
*/
|
||||
export const useMusicCwd = (): string | null => {
|
||||
const rel = useSearchParams()[0].get(MUSIC_PATH_PARAM)?.trim() ?? '';
|
||||
return rel ? `${MUSIC_ROOT}/${rel}` : null;
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { LyricLine } from './lyrics';
|
||||
import { activeLineIndex, parseLyrics } from './lyrics';
|
||||
import { getPlayerTime, subscribePlayerTime } from './player-time';
|
||||
|
||||
export type UseLyrics = {
|
||||
loading: boolean;
|
||||
/** null while loading, and when the track has none. */
|
||||
lines: LyricLine[] | null;
|
||||
synced: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch + parse the current track's lyrics. Gated on `enabled` so nothing is requested until the pane
|
||||
* is actually open — the dock lives on every screen and most listening happens with it closed.
|
||||
*
|
||||
* Deliberately NOT gated on an index "has lyrics" flag the way the mobile app does: the web player's
|
||||
* queue carries only what it needs to stream, and a 404 for a track without lyrics is cheaper than
|
||||
* threading that flag through every producer of a queue.
|
||||
*
|
||||
* Auth goes in the query string rather than a header, matching how this component already builds its
|
||||
* /stream and /cover URLs.
|
||||
*/
|
||||
export const useLyrics = (albumRel: string, file: string, enabled: boolean, token: string | null): UseLyrics => {
|
||||
const [state, setState] = useState<UseLyrics>({ loading: false, lines: null, synced: false });
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !albumRel || !file) {
|
||||
setState({ loading: false, lines: null, synced: false });
|
||||
return;
|
||||
}
|
||||
const url =
|
||||
`/api/music/lyrics?path=${encodeURIComponent(albumRel)}&file=${encodeURIComponent(file)}` +
|
||||
(token ? `&token=${encodeURIComponent(token)}` : '');
|
||||
|
||||
const ctrl = new AbortController();
|
||||
setState({ loading: true, lines: null, synced: false });
|
||||
fetch(url, { signal: ctrl.signal })
|
||||
.then(async (res) => {
|
||||
// 404 is the ordinary "this track has no lyrics" answer, not an error worth surfacing.
|
||||
if (!res.ok) return setState({ loading: false, lines: null, synced: false });
|
||||
const { synced, lines } = parseLyrics(await res.text());
|
||||
setState({ loading: false, lines, synced });
|
||||
})
|
||||
.catch(() => {
|
||||
if (!ctrl.signal.aborted) setState({ loading: false, lines: null, synced: false });
|
||||
});
|
||||
|
||||
return () => ctrl.abort();
|
||||
}, [albumRel, file, enabled, token]);
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
/**
|
||||
* Index of the line to highlight, driven by the engine's position feed.
|
||||
*
|
||||
* The feed ticks every animation frame; this re-renders only when the index actually moves — React bails
|
||||
* out of an identical setState — so a synced sheet repaints about once a line instead of sixty times a
|
||||
* second, even though it lives nowhere near the component that owns the clock.
|
||||
*/
|
||||
export const useActiveLyricIndex = (lines: LyricLine[] | null, synced: boolean): number => {
|
||||
const [index, setIndex] = useState(-1);
|
||||
|
||||
useEffect(() => {
|
||||
if (!synced || !lines) {
|
||||
setIndex(-1);
|
||||
return;
|
||||
}
|
||||
setIndex(activeLineIndex(lines, getPlayerTime()));
|
||||
return subscribePlayerTime((sec) => {
|
||||
const next = activeLineIndex(lines, sec);
|
||||
setIndex((prev) => (prev === next ? prev : next));
|
||||
});
|
||||
}, [lines, synced]);
|
||||
|
||||
return index;
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
|
||||
export const MUSIC_LYRICS_CHANNEL = 'music:lyrics';
|
||||
|
||||
const STORAGE_KEY = 'music.lyrics';
|
||||
// Read once, at import: the play dock re-renders every animation frame, and this is its initial value.
|
||||
const initialOpen = localStorage.getItem(STORAGE_KEY) === '1';
|
||||
|
||||
/**
|
||||
* Whether the lyrics panel is open. Shared by the two microphone buttons — the one in the play dock and
|
||||
* the one on the album header — which are the same switch shown twice, so it lives in a channel rather
|
||||
* than in either component. Seeded from localStorage so the choice survives a reload.
|
||||
*/
|
||||
export const useLyricsOpen = () => {
|
||||
const [open, setOpen] = usePanelChannel<boolean>(MUSIC_LYRICS_CHANNEL, initialOpen);
|
||||
|
||||
// Deliberately not a functional update: useGlobal applies those to the render-time snapshot, and `open`
|
||||
// is that snapshot anyway.
|
||||
const toggleLyrics = () => {
|
||||
localStorage.setItem(STORAGE_KEY, open ? '0' : '1');
|
||||
setOpen(!open);
|
||||
};
|
||||
|
||||
return [open, toggleLyrics] as const;
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import type { FavoriteKind, GroupedFavorites } from './shared';
|
||||
|
||||
const KEY = ['music', 'favorites'] as const;
|
||||
const EMPTY: GroupedFavorites = { tracks: [], albums: [], artists: [] };
|
||||
const groupOf = (kind: FavoriteKind): keyof GroupedFavorites =>
|
||||
kind === 'track' ? 'tracks' : kind === 'album' ? 'albums' : 'artists';
|
||||
|
||||
/**
|
||||
* The user's music favorites (tracks / albums / artists) for the /music workspace, backed by the
|
||||
* platform's `/api/music/favorites`. One shared react-query cache, so every heart reflects the same
|
||||
* state; toggling is optimistic (flips instantly, rolls back on failure).
|
||||
*/
|
||||
export function useMusicFavorites() {
|
||||
const { get, post, delete: del } = useClient();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: KEY,
|
||||
queryFn: () => get<GroupedFavorites>('/music/favorites'),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: ({ on, kind, key }: { on: boolean; kind: FavoriteKind; key: string }) =>
|
||||
on
|
||||
? post('/music/favorites', { kind, key })
|
||||
: del(`/music/favorites?kind=${encodeURIComponent(kind)}&key=${encodeURIComponent(key)}`),
|
||||
onMutate: async ({ on, kind, key }) => {
|
||||
await qc.cancelQueries({ queryKey: KEY });
|
||||
const prev = qc.getQueryData<GroupedFavorites>(KEY) ?? EMPTY;
|
||||
const g = groupOf(kind);
|
||||
qc.setQueryData<GroupedFavorites>(KEY, {
|
||||
...prev,
|
||||
[g]: on ? [key, ...prev[g].filter((k) => k !== key)] : prev[g].filter((k) => k !== key),
|
||||
});
|
||||
return { prev };
|
||||
},
|
||||
onError: (_e, _v, ctx) => {
|
||||
if (ctx?.prev) qc.setQueryData(KEY, ctx.prev);
|
||||
},
|
||||
});
|
||||
|
||||
const isFavorite = (kind: FavoriteKind, key: string) => (data ?? EMPTY)[groupOf(kind)].includes(key);
|
||||
const toggle = (kind: FavoriteKind, key: string) => {
|
||||
if (!key) return;
|
||||
mutation.mutate({ on: !isFavorite(kind, key), kind, key });
|
||||
};
|
||||
|
||||
return { favorites: data ?? EMPTY, isFavorite, toggle };
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useGlobal } from 'hooks/useGlobal';
|
||||
|
||||
// App-wide music player state (react-query-backed via useGlobal, so it's shared across the whole app and
|
||||
// survives route changes). The audio element itself lives in MusicPlayerHost (mounted once in the
|
||||
// persistent DashboardLayout); this hook is the control surface any component uses to drive it.
|
||||
|
||||
export type PlayerTrack = {
|
||||
albumRel: string; // album path relative to the Music root (for stream + cover URLs)
|
||||
file: string; // track filename within the album folder
|
||||
title?: string;
|
||||
artist?: string;
|
||||
};
|
||||
|
||||
export type MusicPlayerState = {
|
||||
queue: PlayerTrack[];
|
||||
index: number;
|
||||
playing: boolean;
|
||||
};
|
||||
|
||||
const INITIAL: MusicPlayerState = { queue: [], index: 0, playing: false };
|
||||
|
||||
export function useMusicPlayer() {
|
||||
const [state, setState] = useGlobal<MusicPlayerState>('MUSIC_PLAYER', INITIAL);
|
||||
|
||||
const playQueue = (queue: PlayerTrack[], index = 0) =>
|
||||
setState({ queue, index: Math.max(0, Math.min(index, Math.max(0, queue.length - 1))), playing: true });
|
||||
// Like playQueue but paused — for restoring a saved "currently playing" on load without auto-playing
|
||||
// (browsers block autoplay on reload anyway; the user resumes with a click).
|
||||
const loadQueue = (queue: PlayerTrack[], index = 0) =>
|
||||
setState({ queue, index: Math.max(0, Math.min(index, Math.max(0, queue.length - 1))), playing: false });
|
||||
const toggle = () => setState((s) => ({ ...s, playing: !s.playing }));
|
||||
const setPlaying = (playing: boolean) => setState((s) => ({ ...s, playing }));
|
||||
// Mirror an engine-driven natural advance into the index WITHOUT restarting playback (the audio engine
|
||||
// has already transitioned to the next track gaplessly; this only updates the UI/highlight).
|
||||
const syncIndex = (index: number) => setState((s) => ({ ...s, index }));
|
||||
const jump = (index: number) =>
|
||||
setState((s) => ({ ...s, index: Math.max(0, Math.min(index, s.queue.length - 1)), playing: true }));
|
||||
const next = () =>
|
||||
setState((s) =>
|
||||
s.index < s.queue.length - 1 ? { ...s, index: s.index + 1, playing: true } : { ...s, playing: false },
|
||||
);
|
||||
const prev = () => setState((s) => (s.index > 0 ? { ...s, index: s.index - 1, playing: true } : s));
|
||||
const close = () => setState(INITIAL);
|
||||
|
||||
const current = state.queue[state.index];
|
||||
return { ...state, current, playQueue, loadQueue, toggle, setPlaying, syncIndex, jump, next, prev, close };
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getPlayerDuration, getPlayerTime, subscribePlayerTime } from './player-time';
|
||||
|
||||
/**
|
||||
* Position + duration, straight off the engine's per-frame feed.
|
||||
*
|
||||
* A scrubber genuinely wants every frame, so — unlike the lyrics — this does re-render at 60fps. Keep it
|
||||
* in the smallest component that draws the bar: whatever calls this hook repaints with it.
|
||||
*/
|
||||
export const usePlayerClock = () => {
|
||||
const [clock, setClock] = useState(() => ({ position: getPlayerTime(), duration: getPlayerDuration() }));
|
||||
|
||||
useEffect(() => subscribePlayerTime((position, duration) => setClock({ position, duration })), []);
|
||||
|
||||
return clock;
|
||||
};
|
||||
Reference in New Issue
Block a user