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,23 +1,25 @@
import { useContext, useEffect, useRef, useState } from "react";
import { Link } from "react-router";
import { PlayerContext, type PlayerItem } from "../contexts/PlayerContext.ts";
import { Trans } from "@lingui/react/macro";
import { t } from "@lingui/core/macro";
import {
PlayerContext,
type PlayerItem,
playerItemKey,
} from "../contexts/PlayerContext.ts";
import { MediaPlayer } from "./MediaPlayer.tsx";
import { fmt } from "../utils/duration.ts";
import Thumbnail from "./Thumbnail.tsx";
type EmbedItem = Extract<PlayerItem, { kind: "embed" }>;
function itemKey(
item: { kind: string; embedUrl?: string; fileUrl?: string } | null,
) {
if (!item) return null;
return item.kind === "embed" ? item.embedUrl : item.fileUrl;
}
// The stored embedUrl is the canonical, non-playing form — it is also rendered
// outside the player, so the autoplay parameter is added here at playback time
// rather than baked into rich_content. Only a fresh play() sets autoplay: a
// session restored from localStorage has no user gesture behind it, so it stays
// paused, matching how MediaPlayer treats file items.
// Bandcamp's EmbeddedPlayer has no autoplay parameter, so it is left alone.
// Bandcamp's EmbeddedPlayer has no autoplay parameter — that is what the native
// playback path (GERBEUR_BANDCAMP_PLAYER=native) exists to solve.
function playbackUrl(item: EmbedItem, autoplay: boolean) {
if (!autoplay) return item.embedUrl;
try {
@@ -35,20 +37,32 @@ function playbackUrl(item: EmbedItem, autoplay: boolean) {
export function GlobalPlayer() {
const {
current,
queue,
queueIndex,
hasNext,
hasPrevious,
resolving,
startTime,
autoplay,
stop,
next,
previous,
playAt,
seekRef,
toggleRef,
onPlayStateChange,
onTimeUpdate,
onEnded,
onError,
} = useContext(PlayerContext);
const ref = useRef<HTMLDivElement>(null);
const currentRowRef = useRef<HTMLLIElement>(null);
const [reduced, setReduced] = useState(false);
const [prevKey, setPrevKey] = useState(itemKey(current));
const [prevKey, setPrevKey] = useState(current ? playerItemKey(current) : null);
if (prevKey !== itemKey(current)) {
setPrevKey(itemKey(current));
const currentKey = current ? playerItemKey(current) : null;
if (prevKey !== currentKey) {
setPrevKey(currentKey);
if (current) setReduced(false);
}
@@ -79,16 +93,27 @@ export function GlobalPlayer() {
};
}, [current]);
// Keep the playing row visible as the queue advances on its own.
useEffect(() => {
currentRowRef.current?.scrollIntoView({ block: "nearest" });
}, [queueIndex]);
if (!current) return null;
const typeClass = current.kind === "embed"
? current.type
: current.mimeType.startsWith("video/")
? "file-video"
: "file-audio";
// Files are classed by their media kind; everything else carries a brand key,
// so a native Bandcamp stream keeps the same styling as the Bandcamp embed.
const typeClass = current.kind === "file"
? (current.mimeType.startsWith("video/") ? "file-video" : "file-audio")
: current.type;
const title = current.title ??
(current.kind === "embed" ? current.embedUrl : current.fileUrl);
(current.kind === "embed"
? current.embedUrl
: current.kind === "file"
? current.fileUrl
: current.streamUrl);
const showQueue = queue.length > 1;
return (
<div
@@ -98,21 +123,66 @@ export function GlobalPlayer() {
ref={ref}
>
<div className="global-player-header">
{current.dumpHref
? (
<Link to={current.dumpHref} className="global-player-title">
{title}
</Link>
)
: <span className="global-player-title">{title}</span>}
{current.artworkUrl && (
<Thumbnail
src={current.artworkUrl}
className="global-player-artwork"
placeholder={{ siteName: current.subtitle }}
placeholderClassName="global-player-artwork"
loading="eager"
/>
)}
<div className="global-player-heading">
{current.dumpHref
? (
<Link to={current.dumpHref} className="global-player-title">
{title}
</Link>
)
: <span className="global-player-title">{title}</span>}
{current.subtitle && (
<span className="global-player-subtitle">{current.subtitle}</span>
)}
</div>
{showQueue && (
<div className="global-player-transport">
<button
type="button"
className="btn btn--ghost"
onClick={previous}
disabled={!hasPrevious}
aria-label={t`Previous track`}
>
</button>
<span className="global-player-position">
{queueIndex + 1} / {queue.length}
</span>
<button
type="button"
className="btn btn--ghost"
onClick={next}
disabled={!hasNext}
aria-label={t`Next track`}
>
</button>
</div>
)}
<button
type="button"
className="btn btn--ghost"
onClick={() => setReduced((r) => !r)}
aria-label={reduced ? t`Expand player` : t`Collapse player`}
>
{reduced ? "▲" : "▼"}
</button>
<button type="button" className="btn btn--ghost" onClick={stop}>
<button
type="button"
className="btn btn--ghost"
onClick={stop}
aria-label={t`Close player`}
>
</button>
</div>
@@ -128,7 +198,8 @@ export function GlobalPlayer() {
/>
</div>
)
: (
: current.kind === "file"
? (
<div className="global-player-media-wrap">
<MediaPlayer
key={current.fileUrl}
@@ -139,11 +210,71 @@ export function GlobalPlayer() {
startTime={startTime}
onPlayStateChange={onPlayStateChange}
onTimeUpdate={onTimeUpdate}
onEnded={onEnded}
seekRef={seekRef}
toggleRef={toggleRef}
/>
</div>
)
: (
<div className="global-player-media-wrap">
<MediaPlayer
// Keyed on the signed URL so a re-resolve remounts the element
// and MediaPlayer's mount-only startTime effect resumes it.
key={current.streamUrl}
src={current.streamUrl}
kind="audio"
mime="audio/mpeg"
trackStyle="progress"
autoplay={autoplay}
startTime={startTime}
onPlayStateChange={onPlayStateChange}
onTimeUpdate={onTimeUpdate}
onEnded={onEnded}
onError={onError}
seekRef={seekRef}
toggleRef={toggleRef}
/>
{resolving && (
<p className="global-player-status">
<Trans>Refreshing stream</Trans>
</p>
)}
</div>
)}
{showQueue && !reduced && (
<ol className="global-player-tracks">
{queue.map((item, i) => {
const isCurrent = i === queueIndex;
const num = item.kind === "stream" ? item.trackNum : undefined;
const dur = item.kind === "stream" ? item.duration : undefined;
return (
<li
key={playerItemKey(item)}
ref={isCurrent ? currentRowRef : undefined}
className={`global-player-track${
isCurrent ? " is-current" : ""
}`}
>
<button type="button" onClick={() => playAt(i)}>
<span className="global-player-track-num">
{num ?? i + 1}
</span>
<span className="global-player-track-title">
{item.title}
</span>
{dur !== undefined && (
<span className="global-player-track-dur">
{fmt(dur)}
</span>
)}
</button>
</li>
);
})}
</ol>
)}
</div>
</div>
);