Skip to content

Fix media keys after HTML5→WebAudio crossover - #23

Merged
switz merged 5 commits into
masterfrom
fix/media-keys-after-crossover
Sep 14, 2026
Merged

switz merged 5 commits into
masterfrom
fix/media-keys-after-crossover

Conversation

@switz

@switz switz commented Sep 10, 2026

Copy link
Copy Markdown
Member

Summary

  • Problem: On Chrome and Safari (macOS), hardware media keys (play/pause/next/prev) stop working once a track crosses over from HTML5 to WebAudio playback. Firefox is unaffected.
  • Root cause: After crossover, the HTML5 <audio> element was paused. Chrome/Safari require an actively-playing media element as an anchor for routing media key events to MediaSession handlers — pausing it severs that link.
  • Fix: Keep the HTML5 element playing silently after crossover. The gain is already at 0 via _html5GainNode, so no sound leaks. The fallback path (no MediaElementAudioSourceNode) uses audio.muted = true. HTML5_ENDED is explicitly ignored in the webaudio machine state, and resetHtml5Element restores gain/muted on deactivation.

Test plan

  • 6 new tests in tests/unit/media-keys.test.ts — 3 of which fail before the fix, all 6 pass after
  • All 348 tests pass (342 existing + 6 new)
  • Manual verification: play a track in Chrome/Safari, wait for WebAudio crossover, confirm media keys still work

🤖 Generated with Claude Code

…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>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 10, 2026

Copy link
Copy Markdown

Codex Review Summary

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

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-10T19:31:22.474455Z 5730812 New commits
🔒 Security Review Completed 2026-09-10T19:12:24.135354Z 7402ce8 PR opened
ℹ️ 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.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 10, 2026

Copy link
Copy Markdown

Deploying gapless with  Cloudflare Pages  Cloudflare Pages

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

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: 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".

Comment thread src/Track.ts Outdated
Comment on lines +297 to +300
this.audio.muted = false;
if (this._html5GainNode && this.ctx) {
this._html5GainNode.gain.cancelScheduledValues(this.ctx.currentTime);
this._html5GainNode.gain.setValueAtTime(1, this.ctx.currentTime);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Comment thread src/machines/track.machine.ts Outdated
LOOKAHEAD_REACHED: {
actions: 'setNotifiedLookahead',
},
HTML5_ENDED: {},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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>

@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: 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".

Comment thread src/Queue.ts Outdated
Comment on lines +25 to +26
const SILENT_WAV_DATA_URI =
'data:audio/wav;base64,UklGRiYAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQIAAAAAAA==';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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>

@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: 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".

Comment thread src/Queue.ts
Comment on lines 343 to +345
destroy(): void {
this._stopMediaSessionAnchor();
this._mediaSessionAnchor = null;

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 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>

@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: 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".

Comment thread src/Queue.ts Outdated
if (!this._mediaSessionAnchor) {
this._mediaSessionAnchor = new Audio(getSilentWavUrl());
this._mediaSessionAnchor.loop = true;
this._mediaSessionAnchor.volume = 0;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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>
@switz
switz merged commit d7e85b7 into master Sep 14, 2026
1 of 2 checks passed
@switz
switz deleted the fix/media-keys-after-crossover branch September 14, 2026 19:30
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