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

@@ -7,13 +7,7 @@ import {
VIEWBOX_W,
WAVEFORM_H,
} from "../utils/waveform.ts";
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")}`;
}
import { fmt } from "../utils/duration.ts";
export const IconPlay = () => (
<svg viewBox="0 0 24 24" fill="currentColor" style={{ marginLeft: "2px" }}>
@@ -119,6 +113,72 @@ function Waveform(
);
}
// ── Volume ────────────────────────────────────────────────────────────────────
// Each queue advance remounts MediaPlayer (the element is keyed on the source),
// which would otherwise snap the volume back to 100% on every track. Hold it
// outside the component and mirror it to localStorage so it also survives a
// reload.
const VOLUME_KEY = "player-volume";
let lastVolume = 1;
let lastMuted = false;
try {
const stored = JSON.parse(localStorage.getItem(VOLUME_KEY) ?? "null");
if (stored && typeof stored.volume === "number") {
lastVolume = Math.min(Math.max(stored.volume, 0), 1);
lastMuted = stored.muted === true;
}
} catch {
// Malformed or unavailable storage — the defaults above stand.
}
function rememberVolume(volume: number, muted: boolean) {
lastVolume = volume;
lastMuted = muted;
try {
localStorage.setItem(VOLUME_KEY, JSON.stringify({ volume, muted }));
} catch {
// Private mode or blocked storage — volume just won't persist.
}
}
// ── Seek bar ──────────────────────────────────────────────────────────────────
/** Slim progress + scrub bar. Used by video, and by audio sources whose peaks
* can't be decoded (cross-origin streams) — where a waveform would be a lie. */
function SeekBar(
{ current, duration, onSeek, onDragChange, className }: {
current: number;
duration: number;
onSeek: (t: number) => void;
onDragChange: (dragging: boolean) => void;
className?: string;
},
) {
const progress = duration > 0 ? current / duration : 0;
return (
<div className={`audio-player-track${className ? ` ${className}` : ""}`}>
<div
className="audio-player-fill"
style={{ width: `${progress * 100}%` }}
/>
<input
type="range"
className="audio-player-range"
min={0}
max={duration || 1}
step={0.01}
value={current}
onMouseDown={() => onDragChange(true)}
onMouseUp={() => onDragChange(false)}
onChange={(e) => onSeek(Number(e.target.value))}
aria-label="Seek"
/>
</div>
);
}
// ── MediaPlayer ───────────────────────────────────────────────────────────────
const HIDE_DELAY = 2500;
@@ -132,6 +192,14 @@ interface MediaPlayerProps {
startTime?: number;
onPlayStateChange?: (playing: boolean) => void;
onTimeUpdate?: (time: number, duration: number) => void;
/** Fired when the media reaches its end — the queue's advance hook. */
onEnded?: () => void;
/** Fired when the element fails to load or decode. For expiring stream URLs
* this is the signal to re-resolve. */
onError?: () => void;
/** "waveform" (default) decodes peaks from the source; "progress" draws a
* slim seek bar and never fetches the media a second time. */
trackStyle?: "waveform" | "progress";
seekRef?: { current: ((t: number) => void) | null };
toggleRef?: { current: (() => void) | null };
}
@@ -146,6 +214,9 @@ export function MediaPlayer(
startTime,
onPlayStateChange,
onTimeUpdate,
onEnded,
onError,
trackStyle = "waveform",
seekRef,
toggleRef,
}: MediaPlayerProps,
@@ -155,8 +226,8 @@ export function MediaPlayer(
const [current, setCurrent] = useState(0);
const [duration, setDuration] = useState(0);
const [dragging, setDragging] = useState(false);
const [volume, setVolume] = useState(1);
const [muted, setMuted] = useState(false);
const [volume, setVolume] = useState(lastVolume);
const [muted, setMuted] = useState(lastMuted);
const [controlsVisible, setControlsVisible] = useState(true);
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -166,11 +237,15 @@ export function MediaPlayer(
// and effect, acceptable since these are only called from async event handlers.
const onPlayStateChangeRef = useRef(onPlayStateChange);
const onTimeUpdateRef = useRef(onTimeUpdate);
const onEndedRef = useRef(onEnded);
const onErrorRef = useRef(onError);
// Sync prop callbacks after every render
useEffect(() => {
onPlayStateChangeRef.current = onPlayStateChange;
onTimeUpdateRef.current = onTimeUpdate;
onEndedRef.current = onEnded;
onErrorRef.current = onError;
});
// Stable function refs — updated via effects, indirected by the registration
@@ -226,6 +301,17 @@ export function MediaPlayer(
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Apply the remembered volume to the freshly-mounted element, which always
// starts at 1.0 regardless of what the last track was playing at.
useEffect(() => {
const a = mediaRef.current;
if (!a) return;
a.volume = volume;
a.muted = muted;
// Mount only: later changes go through changeVolume/toggleMute.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Autoplay on mount (e.g. triggered by play() in PlayerContext)
useEffect(() => {
if (!autoplay) return;
@@ -245,6 +331,8 @@ export function MediaPlayer(
a.pause();
onPlayStateChangeRef.current = undefined;
onTimeUpdateRef.current = undefined;
onEndedRef.current = undefined;
onErrorRef.current = undefined;
};
}, []);
@@ -272,17 +360,24 @@ export function MediaPlayer(
setDuration(a.duration);
onTimeUpdateRef.current?.(a.currentTime, a.duration);
};
const onEnded = () => {
const onEndedEvent = () => {
setPlaying(false);
onPlayStateChangeRef.current?.(false);
onEndedRef.current?.();
};
const onErrorEvent = () => {
setPlaying(false);
onErrorRef.current?.();
};
a.addEventListener("timeupdate", onTime);
a.addEventListener("durationchange", onDuration);
a.addEventListener("ended", onEnded);
a.addEventListener("ended", onEndedEvent);
a.addEventListener("error", onErrorEvent);
return () => {
a.removeEventListener("timeupdate", onTime);
a.removeEventListener("durationchange", onDuration);
a.removeEventListener("ended", onEnded);
a.removeEventListener("ended", onEndedEvent);
a.removeEventListener("error", onErrorEvent);
};
}, [dragging]);
@@ -317,32 +412,32 @@ export function MediaPlayer(
}
};
const seek = (e: React.ChangeEvent<HTMLInputElement>) =>
seekTo(Number(e.target.value));
const changeVolume = (e: React.ChangeEvent<HTMLInputElement>) => {
const v = Number(e.target.value);
const nextMuted = v > 0 && muted ? false : muted;
setVolume(v);
mediaRef.current!.volume = v;
if (v > 0 && muted) {
setMuted(false);
mediaRef.current!.muted = false;
if (nextMuted !== muted) {
setMuted(nextMuted);
mediaRef.current!.muted = nextMuted;
}
rememberVolume(v, nextMuted);
};
const toggleMute = () => {
const next = !muted;
setMuted(next);
mediaRef.current!.muted = next;
rememberVolume(volume, next);
};
const goFullscreen = () => {
(mediaRef.current as HTMLVideoElement).requestFullscreen?.();
};
const progress = duration > 0 ? current / duration : 0;
const track = kind === "audio"
// A waveform needs the raw bytes, which cross-origin streams won't hand over.
// Those get an honest progress bar rather than a permanent loading skeleton.
const track = kind === "audio" && trackStyle === "waveform"
? (
<Waveform
src={src}
@@ -352,24 +447,13 @@ export function MediaPlayer(
/>
)
: (
<div className="audio-player-track">
<div
className="audio-player-fill"
style={{ width: `${progress * 100}%` }}
/>
<input
type="range"
className="audio-player-range"
min={0}
max={duration || 1}
step={0.01}
value={current}
onMouseDown={() => setDragging(true)}
onMouseUp={() => setDragging(false)}
onChange={seek}
aria-label="Seek"
/>
</div>
<SeekBar
current={current}
duration={duration}
onSeek={seekTo}
onDragChange={setDragging}
className={kind === "audio" ? "audio-player-track--stream" : undefined}
/>
);
const controls = (