"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 ( ); } function Muse() { const [tab, setTab] = React.useState("search"); const [view, setView] = React.useState({ k: "welcome" }); const [history, setHistory] = React.useState([]); const [results, setResults] = React.useState(null); const [busy, setBusy] = React.useState(false); const [me, setMe] = React.useState(null); const [snap, setSnap] = React.useState(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 ( <>

muse

search · request · it appears in Navidrome

{me?.user && (
{quotaLeft !== null && ( {quotaLeft} left )} {me.avatar && ( /* eslint-disable-next-line @next/next/no-img-element */ )} {me.display || me.user} {me.role && me.role !== "user" && ( {me.role} )} Log out
)}
{/* 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. */}
{/* 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. */} {tab === "search" && ( <> {history.length > 0 && ( Back )} {view.k === "welcome" && } {view.k === "results" && (results === null ? ( ) : results.length === 0 ? (
No matches. Try adding the artist name.
) : ( {results.map((t, i) => ( ))} ))} {view.k === "album" && ( )} {view.k === "artist" && ( )} )} {tab === "discover" && } {tab === "jobs" && } {tab === "review" && } {tab === "bulk" && } {tab === "settings" && me && } {tab === "admin" && isAdmin && }
); } function Welcome({ onPick }: { onPick: (q: string) => void }) { return (

Find anything

Search a song and press{" "} Enter . Hit play to hear a 30-second preview before you commit, then Get to pull it into your library, tagged.

{EXAMPLES.map((e, i) => ( 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} ))}
); }