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:
@@ -101,7 +101,11 @@ export async function resolveBandcamp(
|
|||||||
"Could not reach Bandcamp",
|
"Could not reach Bandcamp",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Every path out of here that doesn't read the body has to release it, or the
|
||||||
|
// connection stays open until GC. This endpoint is unauthenticated: a caller
|
||||||
|
// hammering random /track/<slug> URLs would otherwise leak one body a request.
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
await res.body?.cancel();
|
||||||
throw new APIException(
|
throw new APIException(
|
||||||
APIErrorCode.SERVER_ERROR,
|
APIErrorCode.SERVER_ERROR,
|
||||||
502,
|
502,
|
||||||
@@ -109,6 +113,7 @@ export async function resolveBandcamp(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!(res.headers.get("content-type") ?? "").startsWith("text/html")) {
|
if (!(res.headers.get("content-type") ?? "").startsWith("text/html")) {
|
||||||
|
await res.body?.cancel();
|
||||||
throw new APIException(
|
throw new APIException(
|
||||||
APIErrorCode.SERVER_ERROR,
|
APIErrorCode.SERVER_ERROR,
|
||||||
502,
|
502,
|
||||||
|
|||||||
@@ -43,6 +43,13 @@ export function JournalCard(
|
|||||||
navigate(dumpUrl(dump));
|
navigate(dumpUrl(dump));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A playable card plays. If playback turns out to be impossible — a Bandcamp
|
||||||
|
// page with no streams and no embed to fall back to — the card must still do
|
||||||
|
// what an unplayable one does rather than swallowing the click.
|
||||||
|
async function handlePlayOrNavigate(rc: NonNullable<typeof playable>) {
|
||||||
|
if (!await playRichContent(rc, dumpUrl(dump))) handleNavigate();
|
||||||
|
}
|
||||||
|
|
||||||
// Mirrors FilePreview (the hot/new feeds) so a video shows its generated
|
// Mirrors FilePreview (the hot/new feeds) so a video shows its generated
|
||||||
// still here too, rather than degrading to a text card with a 🎬.
|
// still here too, rather than degrading to a text card with a 🎬.
|
||||||
const thumbnailUrl = dump.thumbnailMime
|
const thumbnailUrl = dump.thumbnailMime
|
||||||
@@ -151,7 +158,7 @@ export function JournalCard(
|
|||||||
<li
|
<li
|
||||||
className={className}
|
className={className}
|
||||||
onClick={playable
|
onClick={playable
|
||||||
? () => void playRichContent(playable, dumpUrl(dump))
|
? () => void handlePlayOrNavigate(playable)
|
||||||
: handleNavigate}
|
: handleNavigate}
|
||||||
>
|
>
|
||||||
<div className="journal-card-image">
|
<div className="journal-card-image">
|
||||||
|
|||||||
@@ -20,12 +20,20 @@ export default function RichContentCard(
|
|||||||
{ richContent, compact = false, thumbnailOverrideUrl, dumpHref }:
|
{ richContent, compact = false, thumbnailOverrideUrl, dumpHref }:
|
||||||
RichContentCardProps,
|
RichContentCardProps,
|
||||||
) {
|
) {
|
||||||
const { current, playing } = useContext(PlayerContext);
|
const { current, playing, togglePlay } = useContext(PlayerContext);
|
||||||
const { playRichContent, pending } = usePlayRichContent();
|
const { playRichContent, pending } = usePlayRichContent();
|
||||||
|
|
||||||
const canPlay = canPlayRichContent(richContent);
|
const canPlay = canPlayRichContent(richContent);
|
||||||
const thumbnailSrc = thumbnailOverrideUrl ?? richContent.thumbnailUrl;
|
const thumbnailSrc = thumbnailOverrideUrl ?? richContent.thumbnailUrl;
|
||||||
// The dump's own thumbnail overrides the provider's, in the player header too.
|
// The dump's own thumbnail overrides the provider's, in the player header too.
|
||||||
const playable = { ...richContent, thumbnailUrl: thumbnailSrc };
|
const playable = { ...richContent, thumbnailUrl: thumbnailSrc };
|
||||||
|
// Nothing to play and no embed to fall back to: the card body is a link to
|
||||||
|
// the source, so the thumbnail behaves like one rather than going dead.
|
||||||
|
const playOrOpen = async () => {
|
||||||
|
if (!await playRichContent(playable, dumpHref)) {
|
||||||
|
globalThis.open(richContent.url, "_blank", "noopener,noreferrer");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const placeholder = {
|
const placeholder = {
|
||||||
url: richContent.url,
|
url: richContent.url,
|
||||||
@@ -64,7 +72,12 @@ export default function RichContentCard(
|
|||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
void playRichContent(playable, dumpHref);
|
// The button says "Pause" once this card is the one playing, so it
|
||||||
|
// has to pause. Re-playing would re-resolve the album and lose the
|
||||||
|
// queue position. Embeds have no transport of their own, so they
|
||||||
|
// keep restarting, as before.
|
||||||
|
if (isActive && current?.kind === "stream") togglePlay();
|
||||||
|
else void playOrOpen();
|
||||||
}}
|
}}
|
||||||
aria-label={isPlaying ? "Pause" : "Play"}
|
aria-label={isPlaying ? "Pause" : "Play"}
|
||||||
>
|
>
|
||||||
@@ -105,7 +118,7 @@ export default function RichContentCard(
|
|||||||
type="button"
|
type="button"
|
||||||
className={`rich-content-thumbnail-btn${pending ? " is-pending" : ""}`}
|
className={`rich-content-thumbnail-btn${pending ? " is-pending" : ""}`}
|
||||||
disabled={pending}
|
disabled={pending}
|
||||||
onClick={() => void playRichContent(playable, dumpHref)}
|
onClick={() => void playOrOpen()}
|
||||||
aria-label="Play"
|
aria-label="Play"
|
||||||
>
|
>
|
||||||
{thumbnailImg}
|
{thumbnailImg}
|
||||||
|
|||||||
@@ -87,16 +87,22 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) {
|
|||||||
// MediaPlayer's unmount cleanup. Cleared when the new media fires onPlayStateChange(true).
|
// MediaPlayer's unmount cleanup. Cleared when the new media fires onPlayStateChange(true).
|
||||||
const suppressUpdates = useRef(false);
|
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.
|
// Latest state, for callbacks that must not close over a stale render.
|
||||||
// Written in an effect (after render) rather than during it, the same
|
// Written in an effect (after render) rather than during it, the same
|
||||||
// convention MediaPlayer uses for its callback refs.
|
// convention MediaPlayer uses for its callback refs.
|
||||||
const stateRef = useRef({ current, currentTime, playing });
|
const stateRef = useRef({ current, currentTime, playing, queue, queueIndex });
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
stateRef.current = { current, currentTime, playing };
|
stateRef.current = { current, currentTime, playing, queue, queueIndex };
|
||||||
});
|
});
|
||||||
|
|
||||||
const playQueue = useCallback((items: PlayerItem[], startIndex = 0) => {
|
const playQueue = useCallback((items: PlayerItem[], startIndex = 0) => {
|
||||||
if (items.length === 0) return;
|
if (items.length === 0) return;
|
||||||
|
playGen.current++;
|
||||||
suppressUpdates.current = true;
|
suppressUpdates.current = true;
|
||||||
setQueue(items);
|
setQueue(items);
|
||||||
setQueueIndex(Math.min(Math.max(startIndex, 0), items.length - 1));
|
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. */
|
/** Move within the existing queue, as a fresh (autoplaying) item. */
|
||||||
const advanceTo = useCallback((index: number) => {
|
const advanceTo = useCallback((index: number) => {
|
||||||
|
playGen.current++;
|
||||||
suppressUpdates.current = true;
|
suppressUpdates.current = true;
|
||||||
setQueueIndex(index);
|
setQueueIndex(index);
|
||||||
setCurrentTime(0);
|
setCurrentTime(0);
|
||||||
@@ -123,6 +130,7 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
const stop = useCallback(() => {
|
||||||
|
playGen.current++;
|
||||||
setQueue([]);
|
setQueue([]);
|
||||||
setQueueIndex(0);
|
setQueueIndex(0);
|
||||||
setPlaying(false);
|
setPlaying(false);
|
||||||
@@ -147,10 +155,13 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) {
|
|||||||
// seek — while still flipping `playing` false against audio that's running.
|
// seek — while still flipping `playing` false against audio that's running.
|
||||||
if (index === queueIndex) {
|
if (index === queueIndex) {
|
||||||
seekTo(0);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
advanceTo(index);
|
advanceTo(index);
|
||||||
}, [queue.length, queueIndex, advanceTo, seekTo]);
|
}, [queue.length, queueIndex, advanceTo, seekTo, togglePlay]);
|
||||||
|
|
||||||
const next = useCallback(() => {
|
const next = useCallback(() => {
|
||||||
if (queueIndex + 1 < queue.length) advanceTo(queueIndex + 1);
|
if (queueIndex + 1 < queue.length) advanceTo(queueIndex + 1);
|
||||||
@@ -206,6 +217,23 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}], 0);
|
}], 0);
|
||||||
}, [playQueue, stop]);
|
}, [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 (
|
const reresolve = useCallback(async (
|
||||||
item: Extract<PlayerItem, { kind: "stream" }>,
|
item: Extract<PlayerItem, { kind: "stream" }>,
|
||||||
{ resumeAt, shouldAutoplay }: { resumeAt: number; shouldAutoplay: boolean },
|
{ resumeAt, shouldAutoplay }: { resumeAt: number; shouldAutoplay: boolean },
|
||||||
@@ -215,34 +243,48 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const last = lastReresolve.current;
|
const last = lastReresolve.current;
|
||||||
if (last && last.key === key && now - last.at < RERESOLVE_COOLDOWN_MS) {
|
if (last && last.key === key && now - last.at < RERESOLVE_COOLDOWN_MS) {
|
||||||
// Already tried recently — the track is genuinely dead, not just stale.
|
// Already tried recently — the track is genuinely dead, not just stale.
|
||||||
fallBackToEmbed(item);
|
skipOrFallBack(item);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
lastReresolve.current = { key, at: now };
|
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);
|
setResolving(true);
|
||||||
try {
|
try {
|
||||||
const items = await resolveStreamQueue(item);
|
const items = await resolveStreamQueue(item);
|
||||||
const index = Math.max(
|
if (!stillOurs()) return;
|
||||||
items.findIndex((i) =>
|
const found = items.findIndex((i) =>
|
||||||
i.kind === "stream" && i.resolveIndex === item.resolveIndex
|
i.kind === "stream" && i.resolveIndex === item.resolveIndex
|
||||||
),
|
|
||||||
0,
|
|
||||||
);
|
);
|
||||||
|
// 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;
|
suppressUpdates.current = true;
|
||||||
setQueue(items);
|
setQueue(items);
|
||||||
setQueueIndex(index);
|
setQueueIndex(index);
|
||||||
setCurrentTime(resumeAt);
|
setCurrentTime(resume);
|
||||||
setStartTime(resumeAt);
|
setStartTime(resume);
|
||||||
setDuration(0);
|
setDuration(0);
|
||||||
setAutoplay(shouldAutoplay);
|
setAutoplay(shouldAutoplay);
|
||||||
setPlaying(false);
|
setPlaying(false);
|
||||||
} catch {
|
} 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 {
|
} finally {
|
||||||
setResolving(false);
|
if (resolveSeq.current === seq) setResolving(false);
|
||||||
}
|
}
|
||||||
}, [fallBackToEmbed]);
|
}, [fallBackToEmbed, skipOrFallBack]);
|
||||||
|
|
||||||
const onError = useCallback(() => {
|
const onError = useCallback(() => {
|
||||||
const item = stateRef.current.current;
|
const item = stateRef.current.current;
|
||||||
|
|||||||
@@ -21,16 +21,19 @@ export interface PlayableRichContent {
|
|||||||
* before. Bandcamp additionally honours GERBEUR_BANDCAMP_PLAYER: in "native"
|
* before. Bandcamp additionally honours GERBEUR_BANDCAMP_PLAYER: in "native"
|
||||||
* mode the page is resolved to its mp3 streams and played as a queue, which is
|
* mode the page is resolved to its mp3 streams and played as a queue, which is
|
||||||
* what makes it autoplay and lets albums play through. Any failure — offline,
|
* what makes it autoplay and lets albums play through. Any failure — offline,
|
||||||
* preorder-only release, Bandcamp changing its markup — silently falls back to
|
* preorder-only release, Bandcamp changing its markup — falls back to the embed.
|
||||||
* the embed, so this can never leave a card unplayable.
|
*
|
||||||
|
* Returns whether playback actually started. A native-mode page with no stored
|
||||||
|
* embedUrl (an artist root, a /music index, a preorder) has nothing to fall
|
||||||
|
* back to, and the caller has to be told so the click isn't swallowed.
|
||||||
*/
|
*/
|
||||||
export function usePlayRichContent() {
|
export function usePlayRichContent() {
|
||||||
const { play, playQueue } = useContext(PlayerContext);
|
const { play, playQueue } = useContext(PlayerContext);
|
||||||
const [pending, setPending] = useState(false);
|
const [pending, setPending] = useState(false);
|
||||||
|
|
||||||
const playEmbed = useCallback(
|
const playEmbed = useCallback(
|
||||||
(rc: PlayableRichContent, dumpHref?: string) => {
|
(rc: PlayableRichContent, dumpHref?: string): boolean => {
|
||||||
if (!rc.embedUrl) return;
|
if (!rc.embedUrl) return false;
|
||||||
play({
|
play({
|
||||||
kind: "embed",
|
kind: "embed",
|
||||||
embedUrl: rc.embedUrl,
|
embedUrl: rc.embedUrl,
|
||||||
@@ -40,17 +43,15 @@ export function usePlayRichContent() {
|
|||||||
artworkUrl: rc.thumbnailUrl,
|
artworkUrl: rc.thumbnailUrl,
|
||||||
subtitle: rc.siteName,
|
subtitle: rc.siteName,
|
||||||
});
|
});
|
||||||
|
return true;
|
||||||
},
|
},
|
||||||
[play],
|
[play],
|
||||||
);
|
);
|
||||||
|
|
||||||
const playRichContent = useCallback(
|
const playRichContent = useCallback(
|
||||||
async (rc: PlayableRichContent, dumpHref?: string) => {
|
async (rc: PlayableRichContent, dumpHref?: string): Promise<boolean> => {
|
||||||
const native = rc.type === "bandcamp" && BANDCAMP_PLAYER === "native";
|
const native = rc.type === "bandcamp" && BANDCAMP_PLAYER === "native";
|
||||||
if (!native) {
|
if (!native) return playEmbed(rc, dumpHref);
|
||||||
playEmbed(rc, dumpHref);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setPending(true);
|
setPending(true);
|
||||||
try {
|
try {
|
||||||
@@ -62,9 +63,10 @@ export function usePlayRichContent() {
|
|||||||
fallbackArtworkUrl: rc.thumbnailUrl,
|
fallbackArtworkUrl: rc.thumbnailUrl,
|
||||||
});
|
});
|
||||||
playQueue(items, 0);
|
playQueue(items, 0);
|
||||||
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("bandcamp: native playback unavailable, using embed", err);
|
console.warn("bandcamp: native playback unavailable, using embed", err);
|
||||||
playEmbed(rc, dumpHref);
|
return playEmbed(rc, dumpHref);
|
||||||
} finally {
|
} finally {
|
||||||
setPending(false);
|
setPending(false);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user