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

@@ -832,6 +832,37 @@
transition: width 0.1s linear;
}
/* Audio whose peaks can't be decoded (cross-origin streams). Boxed to the
waveform's height so the player doesn't resize between item kinds, with the
bar itself centred inside. */
.audio-player-track--stream {
height: 48px;
background: none;
display: flex;
align-items: center;
}
.audio-player-track--stream::before {
content: "";
position: absolute;
inset: 50% 0 auto 0;
transform: translateY(-50%);
height: 6px;
border-radius: 3px;
background: color-mix(
in srgb,
var(--color-accent) 12%,
var(--color-border) 88%
);
}
.audio-player-track--stream .audio-player-fill {
top: 50%;
bottom: auto;
transform: translateY(-50%);
height: 6px;
border-radius: 3px;
z-index: 1;
}
.audio-player-track--volume {
flex: 1 1 100px;
max-width: 120px;
@@ -1063,6 +1094,105 @@ a.global-player-title:hover {
.global-player.global-player--bandcamp {
max-width: 600px;
}
/* ── Global player: queue ── */
/* The title/artist pair takes the space the bare title used to claim. */
.global-player-heading {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.global-player-heading .global-player-title {
flex: none;
}
.global-player-subtitle {
font-size: 0.75rem;
color: var(--color-text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.global-player-artwork {
width: 36px;
height: 36px;
flex: none;
object-fit: cover;
border-radius: 4px;
}
.global-player-transport {
display: flex;
align-items: center;
gap: 0.25rem;
flex: none;
}
.global-player-position {
font-size: 0.75rem;
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.global-player-transport .btn--ghost:disabled {
opacity: 0.35;
cursor: default;
}
.global-player-status {
margin: 0;
padding: 0 1rem 0.5rem;
font-size: 0.75rem;
color: var(--color-text-muted);
}
.global-player-tracks {
list-style: none;
margin: 0;
padding: 0 0.5rem 0.5rem;
max-height: 40vh;
overflow-y: auto;
}
.global-player-track > button {
display: flex;
align-items: center;
gap: 0.6rem;
width: 100%;
padding: 0.35rem 0.5rem;
background: none;
border: none;
border-left: 3px solid transparent;
color: inherit;
font: inherit;
font-size: 0.8rem;
text-align: left;
cursor: pointer;
}
.global-player-track > button:hover {
background: var(--color-surface-alt, rgba(127, 127, 127, 0.12));
}
/* An accent border rather than a filled pill, so no theme has to un-round it. */
.global-player-track.is-current > button {
border-left-color: var(--color-accent);
font-weight: 600;
}
.global-player-track-num {
flex: none;
min-width: 1.5em;
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
.global-player-track-title {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.global-player-track-dur {
flex: none;
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
.feed-loading-more {
text-align: center;
padding: 1rem;