Three features and a typographic pass over everything. REVIEW. Picking a file and trusting it are different questions, so they now use different numbers. The existing rank decides which candidate to fetch and is right to weigh peer upload slots and queue length -- but none of that says anything about whether the file is the correct recording, and a fast peer must not be able to raise the confidence of a wrong match. So confidence() scores the filename alone: title 0.75, artist 0.25, album +0.05, and a variant tag you did not ask for multiplies by 0.8 AND caps below the auto threshold. Confidence gates the IMPORT, not the download. The transfer completes either way and the file waits in downloads, so approving is instant and rejecting is a delete. Holding the transfer instead would mean the peer is usually gone by the time anyone looks. The two cases it exists for: "02 Creep.flac" in an unsorted folder scores 0.75 -- title exactly right, artist simply absent, the commonest honest case there is -- and must be reviewed rather than binned. And "Creep (Live at Glastonbury)" scores 0.80 with BOTH artist and title perfect; the cap is what stops a live recording auto-filing over the studio version. Ask for "Creep (Acoustic)" and it auto-imports at 0.97, because then the variant is the request. DISCOVER. Related artists seeded from what you have actually imported, plus a recap of top artists, genres and decades. Built on muse's own request history, never on listening data: Navidrome owns play counts and history, and a second source of truth for those would disagree with the one you listen through. EMAIL. Optional, off unless MUSE_SMTP_HOST is set. Sends on import and on review-needed. smtplib is blocking so it runs off the event loop, and every failure is logged and swallowed -- a notification is never worth failing an import over. Google accounts need no configuration, since the identity is an address. Each user owns their own setting under Settings, deliberately not an admin route: an address you cannot change yourself is someone else's chore. DESIGN. A real type scale (display/title/headline/body/callout/caption) whose tracking tightens as size grows, which is the single biggest tell of typography nobody set. Specular top edges on glass surfaces -- blur alone reads as a grey rectangle. A two-part elevation scale, contact shadow plus soft light. Semantic colours are now per-theme: the amber tuned for the dark background failed contrast outright on white, which was exactly the text a reviewer most needs to read. The tab underline became a sliding pill so the eye tracks one object, and Review only appears when something is actually waiting. Add tests/test_matching.py: 13 assertions on the confidence bands rather than the exact scores. 90 tests pass across the three suites. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
395 lines
15 KiB
TypeScript
395 lines
15 KiB
TypeScript
"use client";
|
|
|
|
import * as React from "react";
|
|
import { motion } from "framer-motion";
|
|
import { ArrowLeft, Disc3, Music2 } from "lucide-react";
|
|
|
|
import { api, type Me, type Track } from "@/lib/api";
|
|
import { tint } from "@/lib/colour";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
import { Aurora } from "@/components/aurora";
|
|
import { SearchBar } from "@/components/search-bar";
|
|
import { TrackRow } from "@/components/track-row";
|
|
import { AlbumView } from "@/components/album-view";
|
|
import { ArtistView } from "@/components/artist-view";
|
|
import { BulkImport } from "@/components/bulk-import";
|
|
import { Requests, type JobsSnapshot } from "@/components/requests";
|
|
import { Review } from "@/components/review";
|
|
import { DiscoverPanel } from "@/components/discover";
|
|
import { Settings } from "@/components/settings";
|
|
import { Skeletons } from "@/components/skeletons";
|
|
import { PlayerProvider } from "@/components/player";
|
|
import { ToastProvider } from "@/components/toast";
|
|
import { AdminPanel } from "@/components/admin/admin-panel";
|
|
|
|
type Tab =
|
|
| "search"
|
|
| "discover"
|
|
| "jobs"
|
|
| "review"
|
|
| "bulk"
|
|
| "settings"
|
|
| "admin";
|
|
type View =
|
|
| { k: "welcome" }
|
|
| { k: "results"; q: string }
|
|
| { k: "album"; id: number }
|
|
| { k: "artist"; id: number };
|
|
|
|
const EXAMPLES = [
|
|
"Radiohead Creep",
|
|
"Kendrick Lamar Money Trees",
|
|
"Fleetwood Mac Dreams",
|
|
"Tame Impala The Less I Know The Better",
|
|
"Frank Ocean Ivy",
|
|
];
|
|
|
|
export default function Page() {
|
|
return (
|
|
<ToastProvider>
|
|
<PlayerProvider>
|
|
<Muse />
|
|
</PlayerProvider>
|
|
</ToastProvider>
|
|
);
|
|
}
|
|
|
|
function Muse() {
|
|
const [tab, setTab] = React.useState<Tab>("search");
|
|
const [view, setView] = React.useState<View>({ k: "welcome" });
|
|
const [history, setHistory] = React.useState<View[]>([]);
|
|
const [results, setResults] = React.useState<Track[] | null>(null);
|
|
const [busy, setBusy] = React.useState(false);
|
|
const [me, setMe] = React.useState<Me | null>(null);
|
|
const [snap, setSnap] = React.useState<JobsSnapshot | null>(null);
|
|
|
|
const go = React.useCallback((next: View) => {
|
|
setView((cur) => {
|
|
setHistory((h) => (cur.k === "welcome" ? h : [...h, cur]));
|
|
return next;
|
|
});
|
|
setTab("search");
|
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
|
}, []);
|
|
|
|
const back = () => {
|
|
setHistory((h) => {
|
|
const prev = h[h.length - 1];
|
|
if (prev) setView(prev);
|
|
return h.slice(0, -1);
|
|
});
|
|
};
|
|
|
|
const doSearch = React.useCallback(
|
|
async (q: string) => {
|
|
if (!q.trim()) return;
|
|
go({ k: "results", q });
|
|
setResults(null);
|
|
setBusy(true);
|
|
try {
|
|
const r = await api.search(q);
|
|
setResults(r.results);
|
|
if (r.results[0]) void tint(r.results[0].cover);
|
|
} catch {
|
|
setResults([]);
|
|
}
|
|
setBusy(false);
|
|
},
|
|
[go],
|
|
);
|
|
|
|
const openAlbum = React.useCallback((id: number) => go({ k: "album", id }), [go]);
|
|
const openArtist = React.useCallback((id: number) => go({ k: "artist", id }), [go]);
|
|
|
|
const refresh = React.useCallback(async () => {
|
|
try {
|
|
setSnap(await api.jobs());
|
|
} catch {
|
|
/* a 401 here just means the poll outlived the session */
|
|
}
|
|
// Refetched alongside jobs rather than once at mount so the quota chip
|
|
// counts down as you queue, and so a role change or a disable lands within
|
|
// one poll instead of at next sign-in.
|
|
try {
|
|
setMe(await api.me());
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
void refresh();
|
|
const t = setInterval(refresh, 4000);
|
|
return () => clearInterval(t);
|
|
}, [refresh]);
|
|
|
|
const active =
|
|
(snap?.counts.queued ?? 0) +
|
|
(snap?.counts.searching ?? 0) +
|
|
(snap?.counts.downloading ?? 0);
|
|
const held = snap?.counts.review ?? 0;
|
|
|
|
const isAdmin = me?.role === "admin";
|
|
// Only worth showing when a limit actually exists — a permanent "0 / 0" chip
|
|
// is noise on a single-user install, which is most of them.
|
|
const quotaLeft =
|
|
me?.quota && me.usage ? Math.max(0, me.quota - me.usage.requests) : null;
|
|
|
|
return (
|
|
<>
|
|
<Aurora />
|
|
|
|
<header
|
|
className="sticky top-0 z-40 border-b border-edge bg-[var(--header)] pt-3.5
|
|
backdrop-blur-[22px] backdrop-saturate-[1.7]"
|
|
>
|
|
<div className="mx-auto w-full max-w-[940px] px-4">
|
|
<div className="mb-3.5 flex items-center gap-3">
|
|
<motion.div
|
|
whileHover={{ rotate: 12, scale: 1.06 }}
|
|
transition={{ type: "spring", stiffness: 400, damping: 15 }}
|
|
className="grid h-[38px] w-[38px] shrink-0 place-items-center rounded-xl
|
|
border border-accent-line bg-accent-soft text-accent"
|
|
>
|
|
<Music2 className="h-5 w-5" />
|
|
</motion.div>
|
|
<div className="min-w-0 flex-1">
|
|
<h1 className="text-title leading-none">muse</h1>
|
|
<p className="mt-0.5 text-caption text-dim">
|
|
search · request · it appears in Navidrome
|
|
</p>
|
|
</div>
|
|
{me?.user && (
|
|
<div className="flex items-center gap-2 text-callout text-dim">
|
|
{quotaLeft !== null && (
|
|
<span
|
|
title={`${me.usage!.requests} of ${me.quota} requests used this month`}
|
|
className={cn(
|
|
"hidden rounded-pill border px-2 py-0.5 text-caption nums sm:inline",
|
|
quotaLeft === 0
|
|
? "border-err/50 text-err"
|
|
: quotaLeft <= 5
|
|
? "border-warn/50 text-warn"
|
|
: "border-edge text-dim",
|
|
)}
|
|
>
|
|
{quotaLeft} left
|
|
</span>
|
|
)}
|
|
{me.avatar && (
|
|
/* eslint-disable-next-line @next/next/no-img-element */
|
|
<img
|
|
src={me.avatar}
|
|
alt=""
|
|
className="h-6 w-6 rounded-full border border-edge"
|
|
/>
|
|
)}
|
|
<span className="hidden font-medium text-ink sm:inline">
|
|
{me.display || me.user}
|
|
</span>
|
|
{me.role && me.role !== "user" && (
|
|
<span
|
|
className="rounded-pill border border-accent-line bg-accent-soft px-1.5
|
|
py-0.5 text-caption uppercase tracking-[0.6px] text-accent"
|
|
>
|
|
{me.role}
|
|
</span>
|
|
)}
|
|
<a
|
|
href="/auth/logout"
|
|
className="ring rounded-md px-1 transition-colors hover:text-ink"
|
|
>
|
|
Log out
|
|
</a>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<SearchBar busy={busy} onSearch={doSearch} onAlbum={openAlbum} onArtist={openArtist} />
|
|
|
|
{/* A sliding pill rather than an underline: the moving surface is
|
|
the selection itself, so the eye tracks one object across the
|
|
row instead of watching two things fade. layoutId does the
|
|
interpolation, so it stays correct when tabs appear or vanish
|
|
with a role change. */}
|
|
{/* The mask is the scroll affordance: on a narrow screen the last
|
|
tab fades out at the edge instead of being clipped flat, which
|
|
is the only cue that there is more to the right. */}
|
|
<nav
|
|
className="flex gap-0.5 overflow-x-auto pb-3 pt-2.5 no-scrollbar
|
|
[mask-image:linear-gradient(to_right,#000_calc(100%-28px),transparent)]
|
|
sm:[mask-image:none]"
|
|
>
|
|
{(
|
|
[
|
|
["search", "Search", 0],
|
|
["discover", "Discover", 0],
|
|
["jobs", "Requests", active],
|
|
// Only exists when it is actionable. An always-present tab
|
|
// that is empty nine days in ten is chrome; this one appears
|
|
// exactly when something is waiting on you.
|
|
...(held > 0 || tab === "review"
|
|
? ([["review", "Review", held]] as [Tab, string, number][])
|
|
: []),
|
|
["bulk", "Import", 0],
|
|
["settings", "Settings", 0],
|
|
...(isAdmin ? ([["admin", "Admin", 0]] as [Tab, string, number][]) : []),
|
|
] as [Tab, string, number][]
|
|
).map(([k, label, badge]) => (
|
|
<button
|
|
key={k}
|
|
onClick={() => setTab(k)}
|
|
aria-current={tab === k ? "page" : undefined}
|
|
className={cn(
|
|
"ring relative shrink-0 cursor-pointer rounded-pill px-3 py-1.5",
|
|
"text-callout font-medium transition-colors duration-200",
|
|
tab === k ? "text-ink" : "text-dim hover:text-ink",
|
|
)}
|
|
>
|
|
{tab === k && (
|
|
<motion.span
|
|
layoutId="tab-pill"
|
|
className="absolute inset-0 rounded-pill border border-edge-hi bg-[var(--glass-hi)]"
|
|
transition={{ type: "spring", stiffness: 460, damping: 38 }}
|
|
/>
|
|
)}
|
|
<span className="relative flex items-center gap-1.5">
|
|
{label}
|
|
{badge > 0 && (
|
|
<span
|
|
className={cn(
|
|
"min-w-[17px] rounded-pill px-1 text-caption font-bold nums",
|
|
k === "review"
|
|
? "bg-warn/20 text-warn"
|
|
: "bg-accent-soft text-accent",
|
|
)}
|
|
>
|
|
{badge}
|
|
</span>
|
|
)}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</nav>
|
|
</div>
|
|
</header>
|
|
|
|
<main className="mx-auto w-full max-w-[940px] px-4 pb-32 pt-5">
|
|
{/* Keyed on `tab`, so React remounts and Framer replays the entrance.
|
|
Deliberately NOT wrapped in AnimatePresence mode="wait": that holds
|
|
the incoming panel until the outgoing one's exit animation reports
|
|
finished, which makes navigation depend on an animation completing.
|
|
Anything that stalls the frame loop — a backgrounded tab, a browser
|
|
that throttles rAF — then leaves the UI stuck on the old panel with
|
|
no way forward. A cosmetic transition should never gate navigation. */}
|
|
<motion.div
|
|
key={tab}
|
|
initial={{ opacity: 0, y: 8 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.22 }}
|
|
>
|
|
{tab === "search" && (
|
|
<>
|
|
{history.length > 0 && (
|
|
<motion.button
|
|
initial={{ opacity: 0, x: -8 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
onClick={back}
|
|
className="glass mb-3.5 flex cursor-pointer items-center gap-1.5 rounded-full
|
|
px-3 py-1.5 text-[13px] text-dim transition hover:border-edge-hi hover:text-ink"
|
|
>
|
|
<ArrowLeft className="h-3.5 w-3.5" /> Back
|
|
</motion.button>
|
|
)}
|
|
|
|
{view.k === "welcome" && <Welcome onPick={doSearch} />}
|
|
|
|
{view.k === "results" &&
|
|
(results === null ? (
|
|
<Skeletons />
|
|
) : results.length === 0 ? (
|
|
<div className="py-12 text-center text-dim">
|
|
No matches. Try adding the artist name.
|
|
</div>
|
|
) : (
|
|
<motion.div layout className="flex flex-col gap-[7px]">
|
|
{results.map((t, i) => (
|
|
<TrackRow
|
|
key={t.deezer_id}
|
|
track={t}
|
|
index={i}
|
|
onAlbum={openAlbum}
|
|
onArtist={openArtist}
|
|
onQueued={refresh}
|
|
/>
|
|
))}
|
|
</motion.div>
|
|
))}
|
|
|
|
{view.k === "album" && (
|
|
<AlbumView id={view.id} onArtist={openArtist} onQueued={refresh} />
|
|
)}
|
|
{view.k === "artist" && (
|
|
<ArtistView id={view.id} onAlbum={openAlbum} onQueued={refresh} />
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{tab === "discover" && <DiscoverPanel onArtist={doSearch} />}
|
|
{tab === "jobs" && <Requests snap={snap} refresh={refresh} />}
|
|
{tab === "review" && <Review onDecided={refresh} />}
|
|
{tab === "bulk" && <BulkImport onQueued={refresh} />}
|
|
{tab === "settings" && me && <Settings me={me} refresh={refresh} />}
|
|
{tab === "admin" && isAdmin && <AdminPanel me={me?.user ?? null} />}
|
|
</motion.div>
|
|
</main>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function Welcome({ onPick }: { onPick: (q: string) => void }) {
|
|
return (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 12 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ type: "spring", stiffness: 260, damping: 28 }}
|
|
className="px-5 py-14 text-center"
|
|
>
|
|
<motion.div
|
|
animate={{ y: [0, -7, 0] }}
|
|
transition={{ duration: 4.5, repeat: Infinity, ease: "easeInOut" }}
|
|
className="mx-auto mb-4 grid h-16 w-16 place-items-center rounded-[20px]
|
|
border border-accent-line bg-accent-soft text-accent"
|
|
>
|
|
<Disc3 className="h-7 w-7" />
|
|
</motion.div>
|
|
<h2 className="mb-2 text-display">Find anything</h2>
|
|
<p className="mx-auto max-w-[46ch] text-body text-dim">
|
|
Search a song and press{" "}
|
|
<kbd className="rounded-md border border-edge-hi bg-[var(--glass-hi)] px-1.5 py-0.5 text-caption text-ink">
|
|
Enter
|
|
</kbd>
|
|
. Hit play to hear a 30-second preview before you commit, then <b>Get</b> to pull it into
|
|
your library, tagged.
|
|
</p>
|
|
<div className="mt-5 flex flex-wrap justify-center gap-2">
|
|
{EXAMPLES.map((e, i) => (
|
|
<motion.button
|
|
key={e}
|
|
initial={{ opacity: 0, y: 8 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: 0.1 + i * 0.05 }}
|
|
whileHover={{ y: -2 }}
|
|
onClick={() => onPick(e)}
|
|
className="glass ring cursor-pointer rounded-pill px-3.5 py-1.5 text-callout text-dim
|
|
transition hover:border-accent-line hover:bg-accent-soft hover:text-ink"
|
|
>
|
|
{e}
|
|
</motion.button>
|
|
))}
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
}
|