Paste a Spotify, Apple Music or Deezer playlist URL; muse resolves it, matches every track through the same Deezer path a pasted line takes, and writes an .m3u the library scanner picks up. Written per import rather than at the end, so the playlist appears in Navidrome with the first track that lands and grows as the rest arrive -- twenty minutes of nothing, then everything, is the worse shape. A track rejected in review drops out on the next rewrite, and one that never resolves is reported instead of silently vanishing. Resolve is split from queue on purpose: a shared link is opaque, you cannot tell twelve tracks from four hundred by looking at it, and four hundred is a quota decision. Two of the three sources are scrapes and the code says so plainly. Deezer has a real API. Spotify and Apple Music have no free way to read a public playlist -- Apple's MusicKit needs a paid developer account -- so those read the public page and parse the JSON it already embeds for its own player. No key, no OAuth, no account, and no guarantee: they break whenever either company reshuffles their frontend. So the resolvers fail loudly and name themselves, because an empty playlist and a changed page must not produce the same message. The .m3u uses MUSE_PLAYLIST_PREFIX (/music), not muse's own library path. Both containers mount the same directory at different points and a playlist written with muse's view resolves to nothing on Navidrome's side. Also reorders the hooks in search-bar so the URL-seeding effect is declared after the state it sets, rather than closing over a binding declared below it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
256 lines
8.9 KiB
TypeScript
256 lines
8.9 KiB
TypeScript
"use client";
|
|
|
|
import * as React from "react";
|
|
import { AnimatePresence, motion } from "framer-motion";
|
|
import { Search, X, Loader2 } from "lucide-react";
|
|
import { api, type Suggestions } from "@/lib/api";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
type Item = { kind: "track" | "album" | "artist"; label: string; sub: string; cover: string | null; meta?: string; run: () => void };
|
|
|
|
export function SearchBar({
|
|
initial,
|
|
busy,
|
|
onSearch,
|
|
onAlbum,
|
|
onArtist,
|
|
}: {
|
|
/** Seeds the field from the URL, so a shared link shows what it searched. */
|
|
initial?: string;
|
|
busy?: boolean;
|
|
onSearch: (q: string) => void;
|
|
onAlbum: (id: number) => void;
|
|
onArtist: (id: number) => void;
|
|
}) {
|
|
const [q, setQ] = React.useState(initial ?? "");
|
|
const [items, setItems] = React.useState<Item[]>([]);
|
|
const [open, setOpen] = React.useState(false);
|
|
const [sel, setSel] = React.useState(-1);
|
|
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
// A value seeded from the URL must not pop the suggestion list open over the
|
|
// results that link was for. Only typing counts as asking for suggestions.
|
|
const typed = React.useRef(false);
|
|
const seeded = React.useRef(initial);
|
|
|
|
// Follows the URL on Back/Forward, but never fights what is being typed:
|
|
// only a genuinely different incoming query replaces the field. Declared
|
|
// after the state it sets, or the effect closes over a binding that is not
|
|
// initialised yet.
|
|
React.useEffect(() => {
|
|
if (initial === undefined || initial === seeded.current) return;
|
|
seeded.current = initial;
|
|
typed.current = false;
|
|
setQ(initial);
|
|
setOpen(false);
|
|
}, [initial]);
|
|
|
|
// Debounced so a fast typist makes one upstream call, not eight. The
|
|
// AbortController matters more than the delay: without it a slow response
|
|
// to "rad" can land after "radiohead" and repopulate the stale list.
|
|
React.useEffect(() => {
|
|
const v = q.trim();
|
|
if (v.length < 2) {
|
|
setItems([]);
|
|
setOpen(false);
|
|
return;
|
|
}
|
|
const ac = new AbortController();
|
|
const t = setTimeout(async () => {
|
|
try {
|
|
const d: Suggestions = await api.suggest(v, ac.signal);
|
|
const next: Item[] = [
|
|
...d.tracks.map((t) => ({
|
|
kind: "track" as const,
|
|
label: t.title,
|
|
sub: t.artist,
|
|
cover: t.cover,
|
|
run: () => {
|
|
const nq = `${t.artist} ${t.title}`;
|
|
setQ(nq);
|
|
setOpen(false);
|
|
onSearch(nq);
|
|
},
|
|
})),
|
|
...d.albums.map((a) => ({
|
|
kind: "album" as const,
|
|
label: a.title,
|
|
sub: a.artist,
|
|
cover: a.cover,
|
|
meta: a.nb_tracks ? `${a.nb_tracks} tracks` : "",
|
|
run: () => {
|
|
setOpen(false);
|
|
onAlbum(a.album_id);
|
|
},
|
|
})),
|
|
...d.artists.map((a) => ({
|
|
kind: "artist" as const,
|
|
label: a.name,
|
|
sub: a.nb_album ? `${a.nb_album} releases` : "Artist",
|
|
cover: a.cover,
|
|
run: () => {
|
|
setOpen(false);
|
|
onArtist(a.artist_id);
|
|
},
|
|
})),
|
|
];
|
|
setItems(next);
|
|
setSel(-1);
|
|
setOpen(typed.current && next.length > 0);
|
|
} catch {
|
|
/* aborted or offline — leave the previous list alone */
|
|
}
|
|
}, 180);
|
|
return () => {
|
|
clearTimeout(t);
|
|
ac.abort();
|
|
};
|
|
}, [q, onSearch, onAlbum, onArtist]);
|
|
|
|
// "/" focuses search, the way every music app does.
|
|
React.useEffect(() => {
|
|
const onKey = (e: KeyboardEvent) => {
|
|
const tag = (document.activeElement as HTMLElement)?.tagName;
|
|
if (e.key === "/" && !/^(INPUT|TEXTAREA)$/.test(tag)) {
|
|
e.preventDefault();
|
|
inputRef.current?.focus();
|
|
}
|
|
};
|
|
document.addEventListener("keydown", onKey);
|
|
return () => document.removeEventListener("keydown", onKey);
|
|
}, []);
|
|
|
|
const key = (e: React.KeyboardEvent) => {
|
|
if (e.key === "ArrowDown") {
|
|
e.preventDefault();
|
|
setSel((s) => (items.length ? (s + 1) % items.length : -1));
|
|
} else if (e.key === "ArrowUp") {
|
|
e.preventDefault();
|
|
setSel((s) => (items.length ? (s - 1 + items.length) % items.length : -1));
|
|
} else if (e.key === "Escape") {
|
|
setOpen(false);
|
|
} else if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
if (sel >= 0 && items[sel]) items[sel].run();
|
|
else {
|
|
setOpen(false);
|
|
onSearch(q.trim());
|
|
}
|
|
}
|
|
};
|
|
|
|
const groups: { title: string; kind: Item["kind"] }[] = [
|
|
{ title: "Songs", kind: "track" },
|
|
{ title: "Albums", kind: "album" },
|
|
{ title: "Artists", kind: "artist" },
|
|
];
|
|
|
|
return (
|
|
<div className="relative z-50">
|
|
<div
|
|
className={cn(
|
|
"card flex items-center gap-3 h-[50px] px-3.5 rounded-[20px]",
|
|
"transition-all duration-250 ease-out",
|
|
"focus-within:border-accent-line focus-within:bg-[var(--surface-2)]",
|
|
"focus-within:shadow-[0_0_0_4px_var(--color-accent-soft)]",
|
|
)}
|
|
>
|
|
<Search className="w-[18px] h-[18px] shrink-0 text-dim" />
|
|
<input
|
|
ref={inputRef}
|
|
type="search"
|
|
value={q}
|
|
onChange={(e) => {
|
|
typed.current = true;
|
|
setQ(e.target.value);
|
|
}}
|
|
onKeyDown={key}
|
|
onFocus={() => items.length && setOpen(true)}
|
|
onBlur={() => setTimeout(() => setOpen(false), 120)}
|
|
placeholder="Search songs, albums, artists…"
|
|
aria-label="Search"
|
|
autoComplete="off"
|
|
spellCheck={false}
|
|
className="flex-1 min-w-0 bg-transparent border-0 outline-none text-base
|
|
font-medium tracking-[-0.1px] text-ink placeholder:text-dim
|
|
placeholder:font-normal"
|
|
/>
|
|
{busy && <Loader2 className="w-4 h-4 shrink-0 text-accent animate-spin" />}
|
|
{q && !busy && (
|
|
<button
|
|
onClick={() => {
|
|
setQ("");
|
|
inputRef.current?.focus();
|
|
}}
|
|
aria-label="Clear search"
|
|
className="w-6.5 h-6.5 shrink-0 grid place-items-center rounded-full
|
|
card text-dim hover:text-ink cursor-pointer"
|
|
>
|
|
<X className="w-3.5 h-3.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<AnimatePresence>
|
|
{open && items.length > 0 && (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: -8, scale: 0.985 }}
|
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
exit={{ opacity: 0, y: -8, scale: 0.985 }}
|
|
transition={{ duration: 0.18, ease: [0.22, 0.9, 0.28, 1] }}
|
|
className="sheet absolute top-[calc(100%+8px)] left-0 right-0 z-60 p-1.5
|
|
rounded-[20px] max-h-[min(64vh,460px)] overflow-y-auto no-scrollbar"
|
|
>
|
|
{groups.map((g) => {
|
|
const rows = items.filter((i) => i.kind === g.kind);
|
|
if (!rows.length) return null;
|
|
return (
|
|
<div key={g.kind}>
|
|
<div className="px-2.5 pt-2 pb-1 text-caption font-bold uppercase tracking-[0.9px] text-dim">
|
|
{g.title}
|
|
</div>
|
|
{rows.map((it) => {
|
|
const idx = items.indexOf(it);
|
|
return (
|
|
<div
|
|
key={`${g.kind}-${idx}`}
|
|
role="option"
|
|
aria-selected={idx === sel}
|
|
onMouseDown={(e) => {
|
|
e.preventDefault();
|
|
it.run();
|
|
}}
|
|
onMouseEnter={() => setSel(idx)}
|
|
className={cn(
|
|
"flex items-center gap-3 px-2.5 py-2 rounded-xl cursor-pointer transition-colors",
|
|
idx === sel && "bg-accent-soft",
|
|
)}
|
|
>
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img
|
|
src={it.cover ?? ""}
|
|
alt=""
|
|
loading="lazy"
|
|
className={cn(
|
|
"w-[38px] h-[38px] shrink-0 object-cover bg-faint",
|
|
it.kind === "artist" ? "rounded-full" : "rounded-lg",
|
|
)}
|
|
onError={(e) => (e.currentTarget.style.visibility = "hidden")}
|
|
/>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-sm font-medium clamp1">{it.label}</div>
|
|
<div className="text-xs text-dim clamp1">{it.sub}</div>
|
|
</div>
|
|
{it.meta && <div className="shrink-0 text-caption text-dim">{it.meta}</div>}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
})}
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
);
|
|
}
|