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
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:
78
src/utils/bandcamp.ts
Normal file
78
src/utils/bandcamp.ts
Normal 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
7
src/utils/duration.ts
Normal 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")}`;
|
||||
}
|
||||
34
src/utils/streamSources.ts
Normal file
34
src/utils/streamSources.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user