v3: native bandcamp playback and a queue in the global player
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 43s

The player now holds a queue instead of a single item, so albums play
through and single tracks share the same code path. Adds a "stream" item
kind for expiring, remotely-hosted sources: Bandcamp signs its mp3 URLs
for ~24h, so an item carries the page it came from and re-resolves itself
on error or on a stale restored session, falling back to the iframe embed
if that fails too.

Gated behind GERBEUR_BANDCAMP_PLAYER (default "embed"), injected into a
meta tag the same way as site-name/site-emoji.

Also: player header gains artwork and a subtitle, volume persists across
queue advances and reloads, and cross-origin streams get a plain seek bar
rather than a waveform that can never decode.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SbtjNsT5wvuABnfqhegZEJ
This commit is contained in:
khannurien
2026-08-22 12:14:28 +00:00
parent eb323a8ba8
commit 2606ef4ccc
30 changed files with 1531 additions and 225 deletions

View File

@@ -0,0 +1,86 @@
import { useCallback, useContext, useState } from "react";
import { PlayerContext, type PlayerItem } from "../contexts/PlayerContext.ts";
import { BANDCAMP_PLAYER } from "../config/playerMode.ts";
import { resolveBandcampQueue } from "../utils/bandcamp.ts";
export interface PlayableRichContent {
type: string;
url: string;
title?: string;
embedUrl?: string;
/** Header artwork. Callers with a dump-level override should pass that. */
thumbnailUrl?: string;
/** Header subtitle — "YouTube", "SoundCloud", the site's own name. */
siteName?: string;
}
/**
* Start playback for a rich-content item.
*
* Everything except Bandcamp goes straight to the iframe embed, exactly as
* before. Bandcamp additionally honours GERBEUR_BANDCAMP_PLAYER: in "native"
* mode the page is resolved to its mp3 streams and played as a queue, which is
* what makes it autoplay and lets albums play through. Any failure — offline,
* preorder-only release, Bandcamp changing its markup — silently falls back to
* the embed, so this can never leave a card unplayable.
*/
export function usePlayRichContent() {
const { play, playQueue } = useContext(PlayerContext);
const [pending, setPending] = useState(false);
const playEmbed = useCallback(
(rc: PlayableRichContent, dumpHref?: string) => {
if (!rc.embedUrl) return;
play({
kind: "embed",
embedUrl: rc.embedUrl,
title: rc.title,
type: rc.type,
dumpHref,
artworkUrl: rc.thumbnailUrl,
subtitle: rc.siteName,
});
},
[play],
);
const playRichContent = useCallback(
async (rc: PlayableRichContent, dumpHref?: string) => {
const native = rc.type === "bandcamp" && BANDCAMP_PLAYER === "native";
if (!native) {
playEmbed(rc, dumpHref);
return;
}
setPending(true);
try {
const items: PlayerItem[] = await resolveBandcampQueue(rc.url, {
dumpHref,
embedUrl: rc.embedUrl,
// Bandcamp's own album art wins; this is the fallback if the page
// doesn't carry one.
fallbackArtworkUrl: rc.thumbnailUrl,
});
playQueue(items, 0);
} catch (err) {
console.warn("bandcamp: native playback unavailable, using embed", err);
playEmbed(rc, dumpHref);
} finally {
setPending(false);
}
},
[playEmbed, playQueue],
);
return { playRichContent, pending };
}
/**
* Whether a rich-content item can be played at all. In native mode a Bandcamp
* page is playable even without a stored embedUrl, since the streams are
* resolved from the page itself.
*/
export function canPlayRichContent(rc: PlayableRichContent): boolean {
if (rc.embedUrl) return true;
return rc.type === "bandcamp" && BANDCAMP_PLAYER === "native";
}