Fix media keys after HTML5→WebAudio crossover - #23
Conversation
…afari After crossover, the HTML5 audio element was paused. Chrome and Safari require an actively-playing media element to route hardware media key events to MediaSession handlers — pausing it severed that link. Firefox doesn't have this requirement, which is why it worked there. The fix keeps the HTML5 element playing silently after crossover (gain already at 0 via _html5GainNode), so the browser maintains its media session anchor. The fallback path (no MediaElementAudioSourceNode) uses audio.muted instead. HTML5_ENDED is now explicitly ignored in the webaudio machine state, and resetHtml5Element restores gain/muted on deactivation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
Deploying gapless with
|
| Latest commit: |
9be256e
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://6eb643db.gapless-e1l.pages.dev |
| Branch Preview URL: | https://fix-media-keys-after-crossov.gapless-e1l.pages.dev |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7402ce8065
ℹ️ 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".
| this.audio.muted = false; | ||
| if (this._html5GainNode && this.ctx) { | ||
| this._html5GainNode.gain.cancelScheduledValues(this.ctx.currentTime); | ||
| this._html5GainNode.gain.setValueAtTime(1, this.ctx.currentTime); |
There was a problem hiding this comment.
Pause the retained element before restoring its gain
When NEXT, PREVIOUS, or GOTO deactivates a track after crossover, the webaudio DEACTIVATE transition calls resetHtml5Element without pauseHtml5, so the retained element is still playing. Unmuting it and restoring _html5GainNode to 1 while resetting its position makes the old track restart audibly alongside the newly selected track; the fallback path has the same problem when muted is cleared.
Useful? React with 👍 / 👎.
| LOOKAHEAD_REACHED: { | ||
| actions: 'setNotifiedLookahead', | ||
| }, | ||
| HTML5_ENDED: {}, |
There was a problem hiding this comment.
Transfer the media anchor when the retained element ends
When the retained HTML5 element reaches its natural end, ignoring this event leaves no actively playing media element. Gaplessly scheduled tracks enter webaudio via SCHEDULE_GAPLESS, and already-buffered tracks can enter it directly on PLAY, so neither path calls audio.play() to establish a replacement anchor; under the Chrome/Safari behavior this change is intended to address, media keys therefore stop working at the first track boundary (or earlier after a pause or backward seek lets the HTML5 timeline finish first).
Useful? React with 👍 / 👎.
Instead of keeping each track's HTML5 element playing silently after crossover (which wastes bandwidth/decoder resources and adds lifecycle complexity), use a single silent looping <audio> element on the Queue that keeps the browser's MediaSession anchor alive. This completely decouples media key support from track crossover/deactivation. The silent element is a tiny base64-encoded WAV (44 bytes of header + 2 bytes of silence) that loops at volume 0. It starts when playback starts and pauses when playback pauses, matching the queue state. Track.ts, track.machine.ts, and crossover-flow tests are reverted to their original behavior (HTML5 element is paused after crossover). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 71433b4caf
ℹ️ 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".
| const SILENT_WAV_DATA_URI = | ||
| 'data:audio/wav;base64,UklGRiYAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQIAAAAAAA=='; |
There was a problem hiding this comment.
Give the media anchor a controllable duration
In Chrome, a media element must have a resource duration of at least five seconds to qualify as a controllable media session. This WAV contains one 44.1 kHz sample, so its duration is about 23 microseconds; setting loop does not change the element's reported duration. Once the real track is paused during WebAudio crossover, Chrome can therefore discard this anchor and hardware media keys remain unavailable. Use a silent asset whose intrinsic duration satisfies the browser threshold.
Useful? React with 👍 / 👎.
Chrome requires >= 5s intrinsic duration to treat a media element as a controllable media session. The previous 1-sample WAV (~23μs) was too short. Generate a 10s silent WAV at runtime (8kHz mono 8-bit, ~80KB) instead of embedding base64. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 00e0bc2abc
ℹ️ 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".
| destroy(): void { | ||
| this._stopMediaSessionAnchor(); | ||
| this._mediaSessionAnchor = null; |
There was a problem hiding this comment.
Revoke the silent anchor's blob URL on destruction
When an application repeatedly creates, plays, and destroys queues, each call to createSilentWavUrl() registers a new ~80 KB object URL that remains alive until explicitly revoked or the document unloads. Pausing the element and nulling this reference does not release that URL, so long-lived applications accumulate the generated WAV blobs; retain the URL and call URL.revokeObjectURL() during destroy() before clearing the anchor.
Useful? React with 👍 / 👎.
Avoids accumulating blob URLs when queues are repeatedly created and destroyed. The URL is generated once on first use and shared across all Queue instances. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 57308123f0
ℹ️ 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".
| if (!this._mediaSessionAnchor) { | ||
| this._mediaSessionAnchor = new Audio(getSilentWavUrl()); | ||
| this._mediaSessionAnchor.loop = true; | ||
| this._mediaSessionAnchor.volume = 0; |
There was a problem hiding this comment.
Keep the silent anchor logically audible
On Chrome and Safari, a zero-volume media element is treated as inaudible and may not acquire the media/audio focus required to become the controllable MediaSession anchor, so the new element can play without restoring hardware media-key routing after the real track crosses to WebAudio. The WAV samples are already silence, so leave the element at a positive/default volume rather than setting volume to zero.
Useful? React with 👍 / 👎.
Chrome/Safari may ignore a zero-volume element when determining media focus. Since the WAV content is already silence, leave volume at 1. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Summary
<audio>element was paused. Chrome/Safari require an actively-playing media element as an anchor for routing media key events toMediaSessionhandlers — pausing it severs that link._html5GainNode, so no sound leaks. The fallback path (noMediaElementAudioSourceNode) usesaudio.muted = true.HTML5_ENDEDis explicitly ignored in the webaudio machine state, andresetHtml5Elementrestores gain/muted on deactivation.Test plan
tests/unit/media-keys.test.ts— 3 of which fail before the fix, all 6 pass after🤖 Generated with Claude Code