fix(ios): earbud/MediaSession resume preserves the user-gesture grant

The MediaSession 'play' action called controller.play(), which awaits the
AudioContext bridge BEFORE audio.play(). That await forfeits the iOS
user-activation token from the earbud click, so an interrupted/suspended
AudioContext never resumes -- and play() then returns (not throws) on
ctx-not-running, so the handler's reloadAndPlay() fallback never fired. Result:
earbud resume produced no audio, no native 'playing' event, no playbackState
update, and after repeated no-audio play actions iOS reassigned the audio
session to the next app.

Adds resumeFromGesture(): fires the context resume without awaiting it, calls
audio.play() synchronously in the gesture tail (mirrors swapAndPlay), and on any
rejection reloads the source to re-grab the hardware session instead of a silent
needs-resume. Wired only into the explicit MediaSession 'play' action, so it
cannot auto-resume on an ambiguous pause/route-change (the v1.7.12 earbud-unplug
-> speaker regression stays fixed). play()/tryResume()/pause/silent-watchdog
untouched. Diagnosed via 4-lens + adversary review (SHIP-AS-IS).

Requires on-device confirmation (?ios_debug=1); cannot be unit-verified.
This commit is contained in:
chevron7
2026-06-02 18:30:50 -05:00
parent 2032de9e3c
commit 1a9f6f418a
2 changed files with 54 additions and 9 deletions
+6 -9
View File
@@ -78,15 +78,12 @@ export function useMediaSession() {
}
}
try {
await controller.play();
} catch {
// play() failed (iOS audio session may be invalidated).
// Reload source and try again -- this re-establishes the
// audio hardware connection that iOS drops on interruption.
iosAudioLog("ms:play:fallback-reload", "useMediaSession");
controller.reloadAndPlay();
}
// Explicit user resume -> synchronous gesture-preserving path.
// resumeFromGesture starts audio.play() synchronously (so the iOS
// user-activation token isn't forfeited by an await) and reloads the
// source on failure, so there's no silent needs-resume dead-end and
// no reliance on play() throwing for the fallback to run.
controller.resumeFromGesture();
});
navigator.mediaSession.setActionHandler("pause", () => {
+48
View File
@@ -522,6 +522,54 @@ export class AudioController {
}
}
/**
* Explicit user-gesture resume (MediaSession "play" action / visible play
* button). Unlike play(), this NEVER awaits the AudioContext before
* audio.play() -- awaiting forfeits the iOS user-activation token, which is
* what left earbud-click resume silent and let iOS hand the session to
* another app (WebKit #261858). Mirrors swapAndPlay's synchronous pattern:
* fire the context resume in parallel, call audio.play() synchronously in
* the gesture tail, and on ANY failure reload the source to re-grab the
* hardware session rather than emitting a needs-resume the lock screen
* can't show.
*
* MUST only be called from an explicit user resume gesture, never from an
* ambiguous 'pause'/route-change event (see v1.7.12 / 7b41b91 -- a timer
* resuming on the native pause event routed audio through the speaker on
* earbud unplug).
*/
resumeFromGesture(): void {
iosAudioLog("resumeFromGesture:entry", "audio-controller:resumeFromGesture", this.audio);
if (!this.audio.src) return;
this.setAudioSessionPlayback();
// Kick the AudioContext resume but do NOT await it: audio.play() below
// must run inside the live gesture. The bridge node resumes in parallel;
// currentTime advancing is the liveness signal the silent watchdog
// checks, not ctx.state.
void this.setupAudioContextBridge();
this.cancelSilentPlaybackWatchdog();
this.audio.play().then(() => {
this.startSilentPlaybackWatchdog();
}).catch((err) => {
if (err instanceof DOMException && err.name === "NotAllowedError") {
iosAudioLog("resumeFromGesture:not-allowed", "audio-controller:resumeFromGesture", this.audio);
// A fresh explicit gesture still rejected -> the element/session
// is stale; reloadAndPlay re-establishes the hardware session.
if (this.currentSrc) this.reloadAndPlay();
return;
}
if (err instanceof DOMException && err.name === "AbortError") {
iosAudioLog("resumeFromGesture:abort", "audio-controller:resumeFromGesture", this.audio);
if (this.currentSrc) this.reloadAndPlay();
return;
}
console.error("[AudioController] resumeFromGesture failed:", err);
this.emit("error", { error: err instanceof Error ? err.message : String(err) });
});
}
pause(): void {
this.autoResumeAfterRecovery = false;
this.cancelSilentPlaybackWatchdog();