Files
cdnutterandClaude Opus 5.5 b09533cc00 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>
2026-09-25 10:10:15 -07:00

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);
});