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
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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user