v3: fix the queue and re-resolve edge cases in native bandcamp playback
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 2m59s

Re-resolving a stream is a network round-trip, and nothing checked that the
player still belonged to that item once it landed. Picking another dump —
or closing the player — while a resolve was in flight let the stale result
swap the queue back; from onError, which resolves with autoplay, it would
also start playing over whatever was chosen instead. A generation counter,
bumped by playQueue/advanceTo/stop, now drops any result that no longer owns
the player, with a separate counter owning the resolving flag so a
superseded resolve can't clear a newer one's spinner.

A track that stays dead after a re-resolve is one track, not one album: the
cooldown path now steps to the next entry and only falls back to the iframe
on the last one. A failed resolve still goes straight to the embed, since
that's an album-wide failure rather than a single bad URL. And a track that
has vanished from the release, or lost its streamable flag to Bandcamp's
free-play cap, no longer resumes track 1 at the dead track's offset — the
findIndex miss resets the offset instead of seeking past the end of another
track.

Clicking the playing row rewound it but never resumed: seekTo only moves
currentTime, so re-selecting a finished or paused track looked like a
no-op. It now resumes as well. Same idea in the compact rich-content card,
whose button advertises "Pause" while active but re-played on click — in
native mode that meant a fresh /api/bandcamp/tracks and a restart from
track 1. It pauses for streams now; embeds have no transport of their own
and keep restarting, as before.

playRichContent reports whether playback actually started, so a native-mode
Bandcamp page with no streams and no stored embedUrl — an artist root, a
/music index, a preorder — is no longer a dead click: the journal card
navigates to the dump as it used to, and the rich-content card opens the
source page.

Also: the tralbum fetch releases the response body on its two error paths.
The endpoint is unauthenticated, so random /track/<slug> URLs would leak a
connection per request while also defeating the cache.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E56F55N7m5GGKKEwoYFJtH
This commit is contained in:
khannurien
2026-08-30 17:29:14 +00:00
parent 2606ef4ccc
commit 79b7adce8f
5 changed files with 97 additions and 28 deletions

View File

@@ -87,16 +87,22 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) {
// MediaPlayer's unmount cleanup. Cleared when the new media fires onPlayStateChange(true).
const suppressUpdates = useRef(false);
// Bumped whenever the player is moved somewhere new — a fresh queue, a queue
// step, a stop. An async re-resolve compares it against the value it captured
// to tell whether it still owns the player by the time it lands.
const playGen = useRef(0);
// 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 });
const stateRef = useRef({ current, currentTime, playing, queue, queueIndex });
useEffect(() => {
stateRef.current = { current, currentTime, playing };
stateRef.current = { current, currentTime, playing, queue, queueIndex };
});
const playQueue = useCallback((items: PlayerItem[], startIndex = 0) => {
if (items.length === 0) return;
playGen.current++;
suppressUpdates.current = true;
setQueue(items);
setQueueIndex(Math.min(Math.max(startIndex, 0), items.length - 1));
@@ -113,6 +119,7 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) {
/** Move within the existing queue, as a fresh (autoplaying) item. */
const advanceTo = useCallback((index: number) => {
playGen.current++;
suppressUpdates.current = true;
setQueueIndex(index);
setCurrentTime(0);
@@ -123,6 +130,7 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) {
}, []);
const stop = useCallback(() => {
playGen.current++;
setQueue([]);
setQueueIndex(0);
setPlaying(false);
@@ -147,10 +155,13 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) {
// seek — while still flipping `playing` false against audio that's running.
if (index === queueIndex) {
seekTo(0);
// Rewinding alone would make the click look like a no-op on a row that
// has ended, or that the user paused. Start it again.
if (!stateRef.current.playing) togglePlay();
return;
}
advanceTo(index);
}, [queue.length, queueIndex, advanceTo, seekTo]);
}, [queue.length, queueIndex, advanceTo, seekTo, togglePlay]);
const next = useCallback(() => {
if (queueIndex + 1 < queue.length) advanceTo(queueIndex + 1);
@@ -206,6 +217,23 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) {
}], 0);
}, [playQueue, stop]);
/**
* A track that stays dead after a re-resolve is one track, not one album:
* step over it and keep the queue. Only the last one falls back to the embed.
*/
const skipOrFallBack = useCallback((
item: Extract<PlayerItem, { kind: "stream" }>,
) => {
const { queue: q, queueIndex: i } = stateRef.current;
if (i + 1 < q.length) {
advanceTo(i + 1);
return;
}
fallBackToEmbed(item);
}, [advanceTo, fallBackToEmbed]);
const resolveSeq = useRef(0);
const reresolve = useCallback(async (
item: Extract<PlayerItem, { kind: "stream" }>,
{ resumeAt, shouldAutoplay }: { resumeAt: number; shouldAutoplay: boolean },
@@ -215,34 +243,48 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) {
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);
skipOrFallBack(item);
return;
}
lastReresolve.current = { key, at: now };
// Resolving is a network round-trip against Bandcamp. The user can pick
// another dump, skip on, or close the player while it's in flight — and a
// result that lands after that must not drag the player back here.
const gen = playGen.current;
const stillOurs = () => playGen.current === gen;
// Separate from `gen`: the spinner belongs to the newest resolve, whichever
// item that one is for, so a superseded resolve must not clear it.
const seq = ++resolveSeq.current;
setResolving(true);
try {
const items = await resolveStreamQueue(item);
const index = Math.max(
items.findIndex((i) =>
i.kind === "stream" && i.resolveIndex === item.resolveIndex
),
0,
if (!stillOurs()) return;
const found = items.findIndex((i) =>
i.kind === "stream" && i.resolveIndex === item.resolveIndex
);
// The track can be gone from the release, or no longer streamable once
// Bandcamp's free-play cap kicks in. Falling back to the first track is
// fine; carrying the old offset onto it would seek past its end.
const index = found < 0 ? 0 : found;
const resume = found < 0 ? 0 : resumeAt;
suppressUpdates.current = true;
setQueue(items);
setQueueIndex(index);
setCurrentTime(resumeAt);
setStartTime(resumeAt);
setCurrentTime(resume);
setStartTime(resume);
setDuration(0);
setAutoplay(shouldAutoplay);
setPlaying(false);
} catch {
fallBackToEmbed(item);
// The resolve itself failed (offline, endpoint down) — that's the whole
// album, not one track, so the embed is the right fallback.
if (stillOurs()) fallBackToEmbed(item);
} finally {
setResolving(false);
if (resolveSeq.current === seq) setResolving(false);
}
}, [fallBackToEmbed]);
}, [fallBackToEmbed, skipOrFallBack]);
const onError = useCallback(() => {
const item = stateRef.current.current;