Search Deezer and MusicBrainz together; add preview clips
Search
- Albums and songs search Deezer and MusicBrainz at once and merge the
results: duplicates keep the Deezer copy, best word matches first.
Deezer's search misses albums it carries (Death Magnetic) and most game
soundtracks; MusicBrainz finds them
- MusicBrainz secondary types are shown ("Soundtrack", "Live")
- Settings: Deezer + MusicBrainz (default), Deezer only, MusicBrainz only
- Search results show which service they came from
Previews
- Songs and albums get a play button (cards, rows, detail view, editor,
and Deezer search results) for a 30-second Deezer clip; albums play
their most popular track
- Clips are looked up fresh (Deezer URLs expire), cached 10 minutes, and
streamed through Canon with Range support; hidden lists stay private
- Entries from MusicBrainz or added by hand are matched to Deezer on save
so they can preview too
- "Play previews" toggle in Settings → Profile
Tests cover merging and title/artist matching; `-tags live` runs checks
against the real services.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6d92b46130
commit
b09533cc00
20 files changed
+736
-19
No files matched your search
@@ -0,0 +1,87 @@
|
||||
// One shared <audio> for 30-second previews. Any element with
|
||||
// data-preview-src toggles playback; every button for the same clip shows
|
||||
// the same state (a card and its detail view, for example).
|
||||
import { icon, toast } from "./common.js";
|
||||
|
||||
const audio = new Audio();
|
||||
audio.preload = "none";
|
||||
let currentSrc = null;
|
||||
|
||||
function buttons(src) {
|
||||
return [...document.querySelectorAll("[data-preview-src]")].filter((b) => b.dataset.previewSrc === src);
|
||||
}
|
||||
|
||||
function paintButton(b, state, progress = 0) {
|
||||
b.classList.toggle("is-playing", state === "playing");
|
||||
b.classList.toggle("is-loading", state === "loading");
|
||||
b.setAttribute("aria-pressed", String(state === "playing" || state === "loading"));
|
||||
b.style.setProperty("--p", String(progress));
|
||||
}
|
||||
|
||||
function paint(src, state, progress = 0) {
|
||||
for (const b of buttons(src)) paintButton(b, state, progress);
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (!currentSrc) return;
|
||||
audio.pause();
|
||||
paint(currentSrc, "idle");
|
||||
currentSrc = null;
|
||||
}
|
||||
|
||||
export function togglePreview(src) {
|
||||
if (currentSrc === src) {
|
||||
if (audio.paused) {
|
||||
audio.play();
|
||||
} else {
|
||||
stop();
|
||||
}
|
||||
return;
|
||||
}
|
||||
stop();
|
||||
currentSrc = src;
|
||||
paint(src, "loading");
|
||||
audio.src = src;
|
||||
audio.play().catch(() => { /* reported by the error handler */ });
|
||||
}
|
||||
|
||||
audio.addEventListener("playing", () => currentSrc && paint(currentSrc, "playing", progress()));
|
||||
audio.addEventListener("timeupdate", () => currentSrc && paint(currentSrc, audio.paused ? "idle" : "playing", progress()));
|
||||
audio.addEventListener("ended", stop);
|
||||
audio.addEventListener("error", () => {
|
||||
if (!currentSrc) return;
|
||||
for (const b of buttons(currentSrc)) {
|
||||
b.disabled = true;
|
||||
b.title = "No preview available";
|
||||
}
|
||||
paint(currentSrc, "idle");
|
||||
currentSrc = null;
|
||||
toast("No preview available for this one.", { error: true });
|
||||
});
|
||||
|
||||
function progress() {
|
||||
return audio.duration ? Math.round((audio.currentTime / audio.duration) * 100) : 0;
|
||||
}
|
||||
|
||||
/** A round play/pause button for a clip. */
|
||||
export function previewButton(src, label, className = "play-btn") {
|
||||
const b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.className = className;
|
||||
b.dataset.previewSrc = src;
|
||||
b.setAttribute("aria-label", label);
|
||||
b.setAttribute("aria-pressed", "false");
|
||||
b.title = "Play 30-second preview";
|
||||
b.append(icon("play"), icon("pause"));
|
||||
// Not in the page yet, so paint this button directly.
|
||||
if (src === currentSrc) paintButton(b, audio.paused ? "loading" : "playing", progress());
|
||||
return b;
|
||||
}
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
const b = e.target.closest("[data-preview-src]");
|
||||
if (!b || b.disabled) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
togglePreview(b.dataset.previewSrc);
|
||||
});
|
||||
Reference in new issue
Block a user