Measured against a live instance: 47 failed jobs against 33 imported. Three independent causes, all of which ended the request. The Soulseek server filters some artist names. "Michael Jackson Billie Jean" and bare "Michael Jackson" both return zero responses; "Billie Jean" returns 1083 files. The Weeknd, Kanye West and Frank Ocean behave the same way. Since pick_and_enqueue sent exactly one query of `artist title`, every request for a filtered artist failed reporting "searched 0 files" -- 28 of 29 occurrences. search_queries() now walks a ladder, loosest last, ending at the bare title, and escalates only when a search underdelivers so the common case still costs one search. Dropping the artist from the query does not mean ignoring it: match_ratio() looks for it in the folder path, where an organised share puts it. A rejected transfer ended the request. pick_and_enqueue kept a running maximum and discarded every runner-up, so "Transfer rejected: File not shared." -- a per-file condition where candidate two almost always works -- was terminal, as were timeouts and slskd's 500 on an unreachable peer. The ranked list is now persisted on the job row, ordered to put a different peer next, and try_next_candidate() advances through it. A job fails only once the list is exhausted, and then reports every peer it tried and what each one said. norm() destroyed every non-ASCII character, so "Beyonce" never matched Beyonce, nor Bjork Bjork, nor Dont Don't. It now folds accents and drops apostrophes while leaving & as a separator. Also: availability outranks format in score(), because a FLAC behind 5000 queued users used to beat an immediately available MP3; transfers that stall or that slskd forgets are abandoned instead of sitting in `downloading` forever; searches are deleted only once finished, since deleting one mid-flight makes slskd throw an optimistic-concurrency error and lose the record; a failed search POST is retried rather than failing the job on one blip. The UI moves to a sidebar shell -- rail at tablet, bottom bar on phones -- with neutral chrome and the sampled artwork colour confined to hero areas rather than repainting the whole interface. Adds URL routing, a primitive set under components/ui, and a request sheet showing the attempt ladder. Fixes hover:bg-glass-hi, which was never a valid class, so track rows and the search field had no hover or focus state at all; a reduced-motion path that could strand elements at opacity 0; the login page's hardcoded palette; and the missing 404 and error boundaries. tests/test_acquisition.py covers this path against a faked slskd. It had none. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
149 lines
4.7 KiB
TypeScript
149 lines
4.7 KiB
TypeScript
"use client";
|
|
|
|
import * as React from "react";
|
|
import { AnimatePresence, motion } from "framer-motion";
|
|
import { Square } from "lucide-react";
|
|
import { mmss, type Track } from "@/lib/api";
|
|
|
|
/**
|
|
* 30-second Deezer preview.
|
|
*
|
|
* Deliberately NOT a library player. Playing your own music belongs in
|
|
* Navidrome, which already does offline, CarPlay, gapless and scrobbling, and
|
|
* has a dozen native clients. This exists for one job: confirm you picked the
|
|
* right recording before a Soulseek search is committed to it — which is the
|
|
* cheapest possible guard against importing the wrong take.
|
|
*/
|
|
|
|
type Ctx = {
|
|
playing: number | null;
|
|
toggle: (t: Track) => void;
|
|
stop: () => void;
|
|
};
|
|
|
|
const PlayerCtx = React.createContext<Ctx>({
|
|
playing: null,
|
|
toggle: () => {},
|
|
stop: () => {},
|
|
});
|
|
|
|
export const usePlayer = () => React.useContext(PlayerCtx);
|
|
|
|
export function PlayerProvider({ children }: { children: React.ReactNode }) {
|
|
const audioRef = React.useRef<HTMLAudioElement | null>(null);
|
|
const [track, setTrack] = React.useState<Track | null>(null);
|
|
const [pct, setPct] = React.useState(0);
|
|
const [left, setLeft] = React.useState(30_000);
|
|
|
|
if (typeof window !== "undefined" && !audioRef.current) {
|
|
audioRef.current = new Audio();
|
|
audioRef.current.preload = "none";
|
|
}
|
|
|
|
const stop = React.useCallback(() => {
|
|
const a = audioRef.current;
|
|
if (a) {
|
|
a.pause();
|
|
a.removeAttribute("src");
|
|
a.load();
|
|
}
|
|
setTrack(null);
|
|
setPct(0);
|
|
}, []);
|
|
|
|
const toggle = React.useCallback(
|
|
(t: Track) => {
|
|
const a = audioRef.current;
|
|
if (!a || !t.preview) return;
|
|
if (track?.deezer_id === t.deezer_id) {
|
|
stop();
|
|
return;
|
|
}
|
|
a.pause();
|
|
a.src = t.preview;
|
|
void a.play().catch(() => undefined);
|
|
setTrack(t);
|
|
setPct(0);
|
|
},
|
|
[track, stop],
|
|
);
|
|
|
|
React.useEffect(() => {
|
|
const a = audioRef.current;
|
|
if (!a) return;
|
|
const onTime = () => {
|
|
const d = a.duration || 30;
|
|
setPct((a.currentTime / d) * 100);
|
|
setLeft((d - a.currentTime) * 1000);
|
|
};
|
|
a.addEventListener("timeupdate", onTime);
|
|
a.addEventListener("ended", stop);
|
|
return () => {
|
|
a.removeEventListener("timeupdate", onTime);
|
|
a.removeEventListener("ended", stop);
|
|
};
|
|
}, [stop]);
|
|
|
|
// Stop playback when the tab is hidden — nobody wants a phantom 30s clip.
|
|
React.useEffect(() => {
|
|
const onHide = () => document.hidden && stop();
|
|
document.addEventListener("visibilitychange", onHide);
|
|
return () => document.removeEventListener("visibilitychange", onHide);
|
|
}, [stop]);
|
|
|
|
const value = React.useMemo(
|
|
() => ({ playing: track?.deezer_id ?? null, toggle, stop }),
|
|
[track, toggle, stop],
|
|
);
|
|
|
|
return (
|
|
<PlayerCtx.Provider value={value}>
|
|
{children}
|
|
<AnimatePresence>
|
|
{track && (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 22, x: "-50%" }}
|
|
animate={{ opacity: 1, y: 0, x: "-50%" }}
|
|
exit={{ opacity: 0, y: 22, x: "-50%" }}
|
|
transition={{ type: "spring", stiffness: 380, damping: 32 }}
|
|
className="sheet fixed bottom-4 left-1/2 z-70 flex items-center gap-3
|
|
rounded-[20px] py-2 pl-2 pr-4 w-[min(560px,calc(100vw-28px))]"
|
|
>
|
|
{track.cover && (
|
|
/* eslint-disable-next-line @next/next/no-img-element */
|
|
<img
|
|
src={track.cover}
|
|
alt=""
|
|
className="w-[42px] h-[42px] rounded-[9px] object-cover bg-faint shrink-0"
|
|
/>
|
|
)}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-callout font-semibold clamp1">{track.title}</div>
|
|
<div className="text-xs text-dim clamp1">{track.artist}</div>
|
|
</div>
|
|
<div className="hidden sm:block w-24 h-[3px] rounded-full bg-faint overflow-hidden shrink-0">
|
|
<motion.i
|
|
className="block h-full rounded-full bg-accent"
|
|
animate={{ width: `${pct}%` }}
|
|
transition={{ ease: "linear", duration: 0.25 }}
|
|
/>
|
|
</div>
|
|
<span className="hidden sm:block text-caption text-dim tabular-nums shrink-0">
|
|
{mmss(left)}
|
|
</span>
|
|
<button
|
|
onClick={stop}
|
|
aria-label="Stop preview"
|
|
className="w-8 h-8 shrink-0 grid place-items-center rounded-full card
|
|
text-ink cursor-pointer transition hover:border-accent-line
|
|
hover:text-accent"
|
|
>
|
|
<Square className="w-3 h-3 fill-current" />
|
|
</button>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</PlayerCtx.Provider>
|
|
);
|
|
}
|