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:
@@ -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: () => {},
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user