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>
88 lines
2.7 KiB
JavaScript
88 lines
2.7 KiB
JavaScript
// 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);
|
|
});
|