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

78
src/utils/bandcamp.ts Normal file
View File

@@ -0,0 +1,78 @@
import { API_URL } from "../config/api.ts";
import type { PlayerItem } from "../contexts/PlayerContext.ts";
/** Mirrors the `Tralbum` shape returned by GET /api/bandcamp/tracks. */
interface BandcampTrack {
index: number;
trackNum?: number;
title: string;
duration?: number;
streamUrl: string | null;
streamable: boolean;
capped: boolean;
}
interface BandcampAlbum {
sourceUrl: string;
itemType: "album" | "track";
title?: string;
artist?: string;
artworkUrl?: string;
tracks: BandcampTrack[];
resolvedAt: number;
}
export interface BandcampContext {
dumpHref?: string;
/** Kept on every item so playback can fall back to the iframe later. */
embedUrl?: string;
/** Used only if the page carries no album art of its own. */
fallbackArtworkUrl?: string;
}
/**
* Resolve a Bandcamp page into a playable queue.
*
* Throws on any failure — callers treat that as "fall back to the embed".
* `force` skips the server's cache, for re-resolving after a signature expires.
*/
export async function resolveBandcampQueue(
pageUrl: string,
ctx: BandcampContext = {},
{ force = false }: { force?: boolean } = {},
): Promise<PlayerItem[]> {
const res = await fetch(
`${API_URL}/api/bandcamp/tracks?url=${encodeURIComponent(pageUrl)}${
force ? "&force=1" : ""
}`,
);
if (!res.ok) throw new Error(`resolve failed: ${res.status}`);
const body = await res.json() as { success: boolean; data?: BandcampAlbum };
const album = body.data;
if (!body.success || !album) throw new Error("resolve returned no data");
const items = album.tracks
.filter((t): t is BandcampTrack & { streamUrl: string } =>
t.streamable && typeof t.streamUrl === "string"
)
.map((t): PlayerItem => ({
kind: "stream",
streamUrl: t.streamUrl,
type: "bandcamp",
// The row names the track; the artist rides along on the subtitle line.
title: t.title,
subtitle: album.artist,
duration: t.duration,
trackNum: t.trackNum,
artworkUrl: album.artworkUrl ?? ctx.fallbackArtworkUrl,
dumpHref: ctx.dumpHref,
resolveUrl: album.sourceUrl,
resolveIndex: t.index,
resolvedAt: album.resolvedAt,
embedUrl: ctx.embedUrl,
}));
if (items.length === 0) throw new Error("no streamable tracks");
return items;
}

7
src/utils/duration.ts Normal file
View File

@@ -0,0 +1,7 @@
/** Format seconds as m:ss. Shared by the media player and the player queue. */
export function fmt(s: number): string {
if (!isFinite(s)) return "0:00";
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${m}:${sec.toString().padStart(2, "0")}`;
}

View File

@@ -0,0 +1,34 @@
import type { PlayerItem } from "../contexts/PlayerContext.ts";
import { resolveBandcampQueue } from "./bandcamp.ts";
/**
* Re-resolve the queue a stream item belongs to, for when its signed URL
* expires. Keeping the dispatch here is what lets PlayerProvider handle
* expiry without knowing that Bandcamp exists.
*/
export function resolveStreamQueue(
item: Extract<PlayerItem, { kind: "stream" }>,
): Promise<PlayerItem[]> {
if (item.type === "bandcamp") {
return resolveBandcampQueue(
item.resolveUrl,
{
dumpHref: item.dumpHref,
embedUrl: item.embedUrl,
// Carry the current artwork so a re-resolve can't blank the header.
fallbackArtworkUrl: item.artworkUrl,
},
{ force: true },
);
}
return Promise.reject(new Error(`no resolver for stream type ${item.type}`));
}
/** Signed URLs live 24h; re-resolve before that to avoid a stall mid-play. */
export const STREAM_STALE_MS = 20 * 60 * 60 * 1000;
export function isStreamStale(
item: Extract<PlayerItem, { kind: "stream" }>,
): boolean {
return Date.now() - item.resolvedAt > STREAM_STALE_MS;
}