mirror of
https://github.com/Chevron7Locked/kima-hub.git
synced 2026-06-19 07:37:17 +00:00
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:
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user