Skip to content

Abort a blocked play()'s downloads (5.0.0-alpha.2) - #32

Merged
switz merged 6 commits into
masterfrom
fix/abort-load-on-play-blocked
Nov 10, 2026
Merged

switz merged 6 commits into
masterfrom
fix/abort-load-on-play-blocked

Conversation

@switz

@switz switz commented Nov 10, 2026

Copy link
Copy Markdown
Member

Problem

When the browser refuses HTML5 play() (autoplay policy, NotAllowedError), gapless reports onPlayBlocked and the queue pauses. The track stays in html5, though, with both downloads still running:

  • Web Audio: the HEAD and GET started alongside play().
  • HTML5: the element was switched to preload = 'auto' and keeps buffering.

Nothing cancels either one: unload() refuses the current track, and the element is internal. So a page whose autoplay is blocked downloads the whole first track anyway. Seen in relisten on Firefox: "Autoplay blocked" prompt shown, track still LOADING, a HEAD and two 206 GETs.

Fix

A blocked play() now sends PLAY_BLOCKED to the track before onPlayBlocked runs. From html5 the track returns to idle, as if play() had never been called:

  • isPlaying cleared, progress loop stopped
  • the element released: source dropped and reattached with preload = 'none', which aborts its fetch without an error event
  • a Web Audio load still in flight aborted, and a decode that finishes later dropped (the same bookkeeping as unload())
  • the seek target kept, so the next play() resumes where it would have and starts both loads fresh

The queue's existing PAUSE then reaches an idle track, which ignores it, so the app sees a stopped queue inside onPlayBlocked.

Tests

tests/unit/play-blocked.test.ts:

  • the download is aborted and the element released
  • the app sees a stopped queue inside onPlayBlocked
  • a later play() loads again

All three fail without the fix. 427 tests pass; lint, format, types and the build are clean. Not yet tested in a real browser.

Also in this PR

This branch is based on unload-outside-window, which was published as 5.0.0-alpha.1 but never merged, so its two commits are included here: unloading tracks outside the preload window, and the alpha.1 bump. The last commit bumps to 5.0.0-alpha.2. Not published to npm yet.

switz added 4 commits November 7, 2026 23:05
Moving a track out of the window now aborts its download, drops a decode
in progress and releases its decoded buffer, instead of only cancelling
an unfinished fetch. Late fetch/decode results from an unloaded load are
ignored.
When the browser refused HTML5 play() (autoplay policy, NotAllowedError),
gapless reported onPlayBlocked and the queue paused, but the track stayed
in the html5 state with both downloads running: the Web Audio HEAD and
GET started alongside play(), and the element, switched to preload=auto,
kept buffering. Nothing cancelled them; unload() refuses the current
track, and the element is internal. A page whose autoplay was blocked
downloaded the whole first track anyway.

A blocked play() now sends PLAY_BLOCKED to the track before onPlayBlocked
runs. From html5 it returns to idle, as if play() had never been called:
isPlaying cleared, progress loop stopped, the element released (source
dropped and reattached with preload=none, which aborts its fetch without
an error event), and a Web Audio load still in flight aborted, with any
decode that finishes later dropped (the same bookkeeping as unload()).
The seek target is kept, so the next play() resumes where it would have.
The next play() starts both loads fresh.

The queue's existing PAUSE then reaches an idle track, which ignores it,
so the app sees a stopped queue inside its onPlayBlocked callback.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 10, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-10T21:53:31.620591Z 0d8f3f1 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a24becec58

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/Track.ts
Comment on lines +435 to +436
this.audioBuffer = null;
this._actor.send({ type: 'UNLOAD' });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reset playback type before replaying an unloaded track

When a track that previously played through Web Audio leaves the preload window, unloading clears its buffer but leaves context.playbackType as WEBAUDIO. Returning to that track before it is preloaded again starts HTML5 playback, but neither ACTIVATE nor the HTML5 PLAY transition resets this field. Besides reporting the wrong playback type through TrackInfo, this bypasses the bandwidth-contention guard in Queue._preloadAhead, which checks for playbackType === 'HTML5': after jumping away and back, the next track can download concurrently with the current track's fresh fetch, delaying its crossover. Reset the playback type when entering HTML5 playback so replaying an unloaded track follows the same loading policy as its first play.

Useful? React with 👍 / 👎.

Nothing set playbackType back to HTML5. Before tracks could be unloaded
that didn't matter: a track that had played on Web Audio kept its
buffer, so it replayed on Web Audio. Now unload() can release that
buffer, and replaying the track goes through HTML5 with playbackType
still WEBAUDIO, which the queue reads in two places:

- _preloadAhead's bandwidth guard (defer preloading while the current
  track is on HTML5 and still loading) didn't apply, so the next track
  downloaded alongside the current track's fresh fetch, delaying its
  crossover;
- _cancelScheduledGapless skipped cancelling the HTML5 gain mute.

The html5 state now sets playbackType to HTML5 on entry: it's HTML5
playback by definition, and both transitions into it get it.

Reported by Codex review on #32.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Nov 10, 2026 •

Copy link
Copy Markdown

Deploying gapless with  Cloudflare Pages  Cloudflare Pages

Latest commit: 0d8f3f1
Status: ✅  Deploy successful!
Preview URL: https://95124239.gapless-e1l.pages.dev
Branch Preview URL: https://fix-abort-load-on-play-block.gapless-e1l.pages.dev

View logs

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 269f7af4ee

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/Track.ts
Comment on lines +434 to +436
this._dropPendingDecode();
this.audioBuffer = null;
this._actor.send({ type: 'UNLOAD' });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Release the finished source node when unloading its buffer

When a Web Audio track ends naturally, WEBAUDIO_ENDED transitions it to idle without clearing sourceNode, and the subsequent idle DEACTIVATE does not clear it either. Consequently, unloading that track only nulls audioBuffer; this.sourceNode.buffer still references the decoded audio. Normal sequential playback therefore retains every completed track's decoded buffer despite moving them outside the preload window, defeating the new memory limit and potentially retaining hundreds of MB per track. Clear or dispose of the finished source node during unloading so the decoded buffer can actually be reclaimed.

Useful? React with 👍 / 👎.

unload() nulled audioBuffer, but a track that ended naturally on Web
Audio still held its source node: WEBAUDIO_ENDED goes to idle without
stopping it, and DEACTIVATE in idle doesn't either. The node's buffer is
the decoded audio, so in ordinary sequential playback every finished
track kept its decoded audio (hundreds of MB for a long track) after the
queue unloaded it for leaving the preload window.

unload() now also releases the source node through _stopSourceNode()
(onended cleared, disconnected, dropped). unload() never runs on a
playing or scheduled track, so the node it finds is a finished one.

Reported by Codex review on #32.
@switz
switz merged commit 6da782d into master Nov 10, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant