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

@@ -1,8 +1,68 @@
import { createContext } from "react";
/** Fields every playable item shares, whatever its source. */
interface PlayerItemBase {
title?: string;
dumpHref?: string;
/** Square image for the player header — provider thumbnail, album art, or a
* video still. Raw URL: Thumbnail decides whether to proxy it. */
artworkUrl?: string;
/** Secondary line under the title: the artist, the site name, the filename —
* whatever identifies the source at a glance. */
subtitle?: string;
}
export type PlayerItem =
| { kind: "embed"; embedUrl: string; title?: string; type: string; dumpHref?: string }
| { kind: "file"; fileUrl: string; mimeType: string; title?: string; dumpHref?: string };
| (PlayerItemBase & {
kind: "embed";
embedUrl: string;
type: string;
})
| (PlayerItemBase & {
kind: "file";
fileUrl: string;
mimeType: string;
})
/**
* An expiring, remotely-hosted stream (today: a Bandcamp mp3).
*
* Unlike a "file" item the URL is signed and dies after ~24h, so the item
* carries everything needed to fetch a fresh one: `resolveUrl` is the page it
* came from and `resolveIndex` its position in that page's tracklist. That
* pair is also the item's stable identity, since `streamUrl` changes on every
* re-resolve.
*/
| (PlayerItemBase & {
kind: "stream";
streamUrl: string;
/** Brand key driving `global-player--${type}` styling, e.g. "bandcamp". */
type: string;
/** Known before playback from the tracklist, so rows can show a length. */
duration?: number;
trackNum?: number;
resolveUrl: string;
resolveIndex: number;
/** Epoch ms of the resolution that produced `streamUrl`. */
resolvedAt: number;
/** Iframe embed to fall back to if native playback stops working. */
embedUrl?: string;
});
/**
* Stable identity for an item, used for active-state comparisons and to decide
* when the player is showing something new. Deliberately independent of
* `streamUrl` so a re-resolved track is still recognised as the same track.
*/
export function playerItemKey(item: PlayerItem): string {
switch (item.kind) {
case "embed":
return `embed:${item.embedUrl}`;
case "file":
return `file:${item.fileUrl}`;
case "stream":
return `stream:${item.resolveUrl}#${item.resolveIndex}`;
}
}
export interface PlayerContextValue {
// Playback state — readable by any consumer
@@ -11,14 +71,28 @@ export interface PlayerContextValue {
currentTime: number;
duration: number;
// Queue. Every playback is a queue of at least one item, so single tracks and
// albums share one code path. `current` is always `queue[queueIndex]`.
queue: PlayerItem[];
queueIndex: number;
hasNext: boolean;
hasPrevious: boolean;
/** True while a stream is being (re-)resolved, for spinner affordances. */
resolving: boolean;
// Initial seek offset for the active item (non-zero only for an item restored
// from a previous session) and whether the active MediaPlayer should autoplay.
// A restored item appears paused; a fresh play() autoplays on the user gesture.
// from a previous session, or resumed after a re-resolve) and whether the
// active MediaPlayer should autoplay. A restored item appears paused; a fresh
// play() autoplays on the user gesture.
startTime: number;
autoplay: boolean;
// Control — callable by any consumer
play(item: PlayerItem): void;
playQueue(items: PlayerItem[], startIndex?: number): void;
playAt(index: number): void;
next(): void;
previous(): void;
stop(): void;
seekTo(time: number): void;
togglePlay(): void;
@@ -31,6 +105,10 @@ export interface PlayerContextValue {
// Internal: GlobalPlayer calls these to push state back into the provider
onPlayStateChange(playing: boolean): void;
onTimeUpdate(time: number, duration: number): void;
/** Current item finished — advances the queue, or stops at the end. */
onEnded(): void;
/** The media element failed. For streams, triggers a re-resolve. */
onError(): void;
}
export const PlayerContext = createContext<PlayerContextValue>({
@@ -38,9 +116,18 @@ export const PlayerContext = createContext<PlayerContextValue>({
playing: false,
currentTime: 0,
duration: 0,
queue: [],
queueIndex: 0,
hasNext: false,
hasPrevious: false,
resolving: false,
startTime: 0,
autoplay: false,
play: () => {},
playQueue: () => {},
playAt: () => {},
next: () => {},
previous: () => {},
stop: () => {},
seekTo: () => {},
togglePlay: () => {},
@@ -48,4 +135,6 @@ export const PlayerContext = createContext<PlayerContextValue>({
toggleRef: { current: null },
onPlayStateChange: () => {},
onTimeUpdate: () => {},
onEnded: () => {},
onError: () => {},
});

View File

@@ -1,39 +1,83 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { PlayerContext, type PlayerItem } from "./PlayerContext.ts";
import {
PlayerContext,
type PlayerItem,
playerItemKey,
} from "./PlayerContext.ts";
import { isStreamStale, resolveStreamQueue } from "../utils/streamSources.ts";
const STORAGE_KEY = "player";
// Snapshot persisted across reloads: which item was playing and how far in.
/**
* Snapshot persisted across reloads: the whole queue, which entry was playing
* and how far in.
*
* v1 stored a single `{ item, time }`. `readSession` still accepts that shape
* so a session written by the previous build survives the upgrade.
*/
interface StoredSession {
item: PlayerItem;
v: 2;
queue: PlayerItem[];
index: number;
time: number;
}
function readSession(): StoredSession | null {
function isPlayerItem(value: unknown): value is PlayerItem {
if (!value || typeof value !== "object") return false;
const kind = (value as { kind?: unknown }).kind;
return kind === "embed" || kind === "file" || kind === "stream";
}
function readSession(): { queue: PlayerItem[]; index: number; time: number } | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<StoredSession>;
if (!parsed.item || (parsed.item.kind !== "embed" && parsed.item.kind !== "file")) {
return null;
const parsed = JSON.parse(raw) as Record<string, unknown>;
const time = Number(parsed.time) || 0;
const rawQueue = parsed.queue;
if (Array.isArray(rawQueue)) {
const queue = rawQueue.filter(isPlayerItem);
if (queue.length === 0) return null;
const index = Math.min(
Math.max(Number(parsed.index) || 0, 0),
queue.length - 1,
);
return { queue, index, time };
}
return { item: parsed.item as PlayerItem, time: Number(parsed.time) || 0 };
// Legacy v1 single-item session.
if (isPlayerItem(parsed.item)) {
return { queue: [parsed.item], index: 0, time };
}
return null;
} catch {
return null;
}
}
export function PlayerProvider({ children }: { children: React.ReactNode }) {
const restored = useRef(readSession()).current;
/** Don't retry the same failing track more than once per window. */
const RERESOLVE_COOLDOWN_MS = 30_000;
const [current, setCurrent] = useState<PlayerItem | null>(restored?.item ?? null);
export function PlayerProvider({ children }: { children: React.ReactNode }) {
// Lazy initialiser: readSession runs exactly once, on first render.
const [restored] = useState(readSession);
const [queue, setQueue] = useState<PlayerItem[]>(restored?.queue ?? []);
const [queueIndex, setQueueIndex] = useState(restored?.index ?? 0);
const [playing, setPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(restored?.time ?? 0);
const [duration, setDuration] = useState(0);
// Resume offset for the active item — only the restored item carries one.
// Resume offset for the active item — carried by a restored item, and by an
// item whose stream URL was re-resolved mid-listen.
const [startTime, setStartTime] = useState(restored?.time ?? 0);
// Restored items start paused (no user gesture); fresh play() autoplays.
const [autoplay, setAutoplay] = useState(false);
const [resolving, setResolving] = useState(false);
const current = queue[queueIndex] ?? null;
const hasNext = queueIndex + 1 < queue.length;
const hasPrevious = queueIndex > 0;
// GlobalPlayer registers the active MediaPlayer's imperative handles here
const seekRef = useRef<((t: number) => void) | null>(null);
@@ -43,9 +87,34 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) {
// MediaPlayer's unmount cleanup. Cleared when the new media fires onPlayStateChange(true).
const suppressUpdates = useRef(false);
const play = useCallback((item: PlayerItem) => {
// Latest state, for callbacks that must not close over a stale render.
// Written in an effect (after render) rather than during it, the same
// convention MediaPlayer uses for its callback refs.
const stateRef = useRef({ current, currentTime, playing });
useEffect(() => {
stateRef.current = { current, currentTime, playing };
});
const playQueue = useCallback((items: PlayerItem[], startIndex = 0) => {
if (items.length === 0) return;
suppressUpdates.current = true;
setCurrent(item);
setQueue(items);
setQueueIndex(Math.min(Math.max(startIndex, 0), items.length - 1));
setCurrentTime(0);
setDuration(0);
setStartTime(0);
setAutoplay(true);
setPlaying(false);
}, []);
const play = useCallback((item: PlayerItem) => {
playQueue([item], 0);
}, [playQueue]);
/** Move within the existing queue, as a fresh (autoplaying) item. */
const advanceTo = useCallback((index: number) => {
suppressUpdates.current = true;
setQueueIndex(index);
setCurrentTime(0);
setDuration(0);
setStartTime(0);
@@ -54,7 +123,8 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) {
}, []);
const stop = useCallback(() => {
setCurrent(null);
setQueue([]);
setQueueIndex(0);
setPlaying(false);
setCurrentTime(0);
setDuration(0);
@@ -70,6 +140,31 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) {
toggleRef.current?.();
}, []);
const playAt = useCallback((index: number) => {
if (index < 0 || index >= queue.length) return;
// Re-selecting the playing row restarts it. Going through advanceTo would
// leave the index unchanged — so the element would never remount and never
// seek — while still flipping `playing` false against audio that's running.
if (index === queueIndex) {
seekTo(0);
return;
}
advanceTo(index);
}, [queue.length, queueIndex, advanceTo, seekTo]);
const next = useCallback(() => {
if (queueIndex + 1 < queue.length) advanceTo(queueIndex + 1);
}, [queueIndex, queue.length, advanceTo]);
/** Restart the track first, like every other music player, then step back. */
const previous = useCallback(() => {
if (stateRef.current.currentTime > 3 || queueIndex === 0) {
seekTo(0);
return;
}
advanceTo(queueIndex - 1);
}, [queueIndex, advanceTo, seekTo]);
const onPlayStateChange = useCallback((p: boolean) => {
if (p) suppressUpdates.current = false;
setPlaying(p);
@@ -81,25 +176,127 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) {
setDuration(d);
}, []);
// ── Persistence ────────────────────────────────────────────────────────────────
// Write the item to storage as soon as it changes (survives crashes), then refresh
// the playback position on pagehide — which also fires on a normal reload — so the
// resume offset is accurate without thrashing localStorage on every timeupdate.
const onEnded = useCallback(() => {
if (stateRef.current.current && queueIndex + 1 < queue.length) {
advanceTo(queueIndex + 1);
return;
}
setPlaying(false);
}, [queueIndex, queue.length, advanceTo]);
// ── Expiring streams ───────────────────────────────────────────────────────────
// Bandcamp signs its mp3 URLs for ~24h. A session restored the next day, or a
// long listen, will 403. Re-resolve the source page, swap in fresh URLs and
// resume at the same offset; if that fails too, fall back to the iframe embed.
const lastReresolve = useRef<{ key: string; at: number } | null>(null);
const fallBackToEmbed = useCallback((
item: Extract<PlayerItem, { kind: "stream" }>,
) => {
if (!item.embedUrl) {
stop();
return;
}
playQueue([{
kind: "embed",
embedUrl: item.embedUrl,
type: item.type,
title: item.title,
dumpHref: item.dumpHref,
}], 0);
}, [playQueue, stop]);
const reresolve = useCallback(async (
item: Extract<PlayerItem, { kind: "stream" }>,
{ resumeAt, shouldAutoplay }: { resumeAt: number; shouldAutoplay: boolean },
) => {
const key = playerItemKey(item);
const now = Date.now();
const last = lastReresolve.current;
if (last && last.key === key && now - last.at < RERESOLVE_COOLDOWN_MS) {
// Already tried recently — the track is genuinely dead, not just stale.
fallBackToEmbed(item);
return;
}
lastReresolve.current = { key, at: now };
setResolving(true);
try {
const items = await resolveStreamQueue(item);
const index = Math.max(
items.findIndex((i) =>
i.kind === "stream" && i.resolveIndex === item.resolveIndex
),
0,
);
suppressUpdates.current = true;
setQueue(items);
setQueueIndex(index);
setCurrentTime(resumeAt);
setStartTime(resumeAt);
setDuration(0);
setAutoplay(shouldAutoplay);
setPlaying(false);
} catch {
fallBackToEmbed(item);
} finally {
setResolving(false);
}
}, [fallBackToEmbed]);
const onError = useCallback(() => {
const item = stateRef.current.current;
if (item?.kind !== "stream") return;
void reresolve(item, {
resumeAt: stateRef.current.currentTime,
shouldAutoplay: true,
});
}, [reresolve]);
// A session restored from a previous day holds URLs that are already dead.
// Refresh them up front so the first press of play doesn't visibly stall.
useEffect(() => {
if (current) {
if (!restored) return;
const item = restored.queue[restored.index];
if (item?.kind === "stream" && isStreamStale(item)) {
// Genuinely an external-system sync: the stored URLs are dead and only
// the network can replace them. The setState this triggers is the point.
// eslint-disable-next-line react-hooks/set-state-in-effect
void reresolve(item, {
resumeAt: restored.time,
shouldAutoplay: false,
});
}
// Runs once, against the session captured at mount.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// ── Persistence ────────────────────────────────────────────────────────────────
// Write the queue to storage as soon as it changes (survives crashes), then
// refresh the playback position on pagehide — which also fires on a normal
// reload — so the resume offset is accurate without thrashing localStorage on
// every timeupdate.
useEffect(() => {
if (queue.length > 0) {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ item: current, time: currentTime } satisfies StoredSession),
JSON.stringify(
{ v: 2, queue, index: queueIndex, time: currentTime } satisfies StoredSession,
),
);
} else {
localStorage.removeItem(STORAGE_KEY);
}
// currentTime intentionally omitted from deps — see pagehide writer below.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [current]);
}, [queue, queueIndex]);
const sessionRef = useRef<StoredSession | null>(null);
sessionRef.current = current ? { item: current, time: currentTime } : null;
useEffect(() => {
sessionRef.current = queue.length > 0
? { v: 2, queue, index: queueIndex, time: currentTime }
: null;
});
useEffect(() => {
const persist = () => {
@@ -118,9 +315,18 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) {
playing,
currentTime,
duration,
queue,
queueIndex,
hasNext,
hasPrevious,
resolving,
startTime,
autoplay,
play,
playQueue,
playAt,
next,
previous,
stop,
seekTo,
togglePlay,
@@ -128,19 +334,32 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) {
toggleRef,
onPlayStateChange,
onTimeUpdate,
onEnded,
onError,
}), [
current,
playing,
currentTime,
duration,
queue,
queueIndex,
hasNext,
hasPrevious,
resolving,
startTime,
autoplay,
play,
playQueue,
playAt,
next,
previous,
stop,
seekTo,
togglePlay,
onPlayStateChange,
onTimeUpdate,
onEnded,
onError,
]);
return (