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: () => {},
});