fix(ios): await AudioContext resume and detect silent playback

Two coordinated changes addressing the post-long-suspension case where
iOS's nap-mode (or any deep background) leaves the AudioContext
suspended/interrupted, audio.play() resolves immediately, and playback
shows 'playing' but produces no sound.

1. setupAudioContextBridge is now awaited. play() and tryResume() check
   the final context state and emit needs-resume if not 'running' rather
   than racing into a doomed play() call.

2. Silent-playback watchdog. After audio.play() resolves we start a
   2.5s timer; if no timeupdate fires (audio is genuinely silent), we
   pause and emit needs-resume. The UI prompt's tap is a fresh user
   gesture that can actually resume the context.

Both gated to iOS standalone PWA only -- desktop / Android / iOS-Safari
keep the bare-element path.
This commit is contained in:
chevron7
2026-05-13 14:57:26 -05:00
parent 439fa68657
commit daf6210b12
+108 -11
View File
@@ -65,6 +65,15 @@ export class AudioController {
private mediaSourceNode: MediaElementAudioSourceNode | null = null;
private audioContextBridgeAttempted = false;
// Silent-playback watchdog: after audio.play() resolves, expect a real
// timeupdate event within SILENT_PLAYBACK_TIMEOUT_MS. If none arrives,
// assume iOS has us in a "playing but silent" state (audio.paused=false,
// MediaSession.playbackState="playing", no audio routing). Pause and emit
// needs-resume so the UI renders a Tap-to-resume prompt; the user tap is
// a fresh user gesture that can actually resume the AudioContext.
private silentPlaybackTimeout: ReturnType<typeof setTimeout> | null = null;
private readonly SILENT_PLAYBACK_TIMEOUT_MS = 2500;
constructor(audio: HTMLAudioElement) {
this.audio = audio;
this.audio.preload = "auto";
@@ -106,27 +115,57 @@ export class AudioController {
}
}
private setupAudioContextBridge(): void {
/**
* Ensure the iOS AudioContext bridge is set up and the context is running.
* Returns the final context state (or null if no bridge is needed on this
* platform / browser). Always awaits resume() rather than fire-and-forget
* so callers can gate play() on the actual ready state -- iOS nap-mode
* and long backgrounding can leave the context "suspended" or
* "interrupted" and a play() before resume completes produces silent
* playback (audio.paused=false but no audio routing).
*/
private async setupAudioContextBridge(): Promise<AudioContextState | null> {
if (this.audioContextBridgeAttempted) {
// Already attempted; resume if suspended (idempotent, cheap).
this.audioContext?.resume?.().catch(() => {});
return;
if (!this.audioContext) return null;
if (this.audioContext.state !== "running") {
try {
await this.audioContext.resume();
} catch (err) {
iosAudioLog(
"audio-context:resume-rejected",
"audio-controller:setupAudioContextBridge",
this.audio,
{ error: err instanceof Error ? err.message : String(err), state: this.audioContext.state },
);
}
}
return this.audioContext.state;
}
if (!this.isIosStandalone()) return;
if (!this.isIosStandalone()) return null;
this.audioContextBridgeAttempted = true;
try {
const AC = window.AudioContext || (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!AC) return;
if (!AC) return null;
this.audioContext = new AC();
this.mediaSourceNode = this.audioContext.createMediaElementSource(this.audio);
this.mediaSourceNode.connect(this.audioContext.destination);
this.audioContext.resume().catch(() => {});
try {
await this.audioContext.resume();
} catch (err) {
iosAudioLog(
"audio-context:initial-resume-rejected",
"audio-controller:setupAudioContextBridge",
this.audio,
{ error: err instanceof Error ? err.message : String(err), state: this.audioContext.state },
);
}
iosAudioLog(
"audio-context:bridge-up",
"audio-controller:setupAudioContextBridge",
this.audio,
{ state: this.audioContext.state },
);
return this.audioContext.state;
} catch (err) {
iosAudioLog(
"audio-context:bridge-fail",
@@ -134,6 +173,7 @@ export class AudioController {
this.audio,
{ error: err instanceof Error ? err.message : String(err) },
);
return null;
}
}
@@ -169,6 +209,7 @@ export class AudioController {
add("timeupdate", () => {
this.cancelStallGrace();
this.cancelSilentPlaybackWatchdog();
this.emit("timeupdate", { time: this.audio.currentTime });
});
@@ -342,15 +383,53 @@ export class AudioController {
}
}
private startSilentPlaybackWatchdog(): void {
this.cancelSilentPlaybackWatchdog();
if (!this.isIosStandalone()) return;
this.silentPlaybackTimeout = setTimeout(() => {
this.silentPlaybackTimeout = null;
// If we're "playing" but timeupdate hasn't fired (cancelled this),
// iOS is silently swallowing the audio. Pause and prompt the user.
if (!this.audio.paused && !this.audio.ended) {
iosAudioLog(
"silent-playback:detected",
"audio-controller:silent-watchdog",
this.audio,
{ ctxState: this.audioContext?.state ?? null },
);
this.audio.pause();
this.emit("needs-resume");
}
}, this.SILENT_PLAYBACK_TIMEOUT_MS);
}
private cancelSilentPlaybackWatchdog(): void {
if (this.silentPlaybackTimeout) {
clearTimeout(this.silentPlaybackTimeout);
this.silentPlaybackTimeout = null;
}
}
async play(): Promise<void> {
iosAudioLog("play:entry", "audio-controller:play", this.audio);
if (!this.audio.src) return;
this.setAudioSessionPlayback();
this.setupAudioContextBridge();
const ctxState = await this.setupAudioContextBridge();
if (ctxState && ctxState !== "running") {
iosAudioLog(
"play:context-not-running",
"audio-controller:play",
this.audio,
{ state: ctxState },
);
this.emit("needs-resume");
return;
}
try {
await this.audio.play();
this.startSilentPlaybackWatchdog();
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") {
iosAudioLog("play:abort-error", "audio-controller:play", this.audio);
@@ -363,7 +442,7 @@ export class AudioController {
}
if (err instanceof DOMException && err.name === "NotAllowedError") {
iosAudioLog("play:not-allowed", "audio-controller:play", this.audio);
// User gesture required emit needs-resume so UI can prompt
// User gesture required -- emit needs-resume so UI can prompt
this.emit("needs-resume");
return;
}
@@ -377,10 +456,23 @@ export class AudioController {
if (!this.audio.paused) return true;
this.setAudioSessionPlayback();
this.setupAudioContextBridge();
const ctxState = await this.setupAudioContextBridge();
if (ctxState && ctxState !== "running") {
iosAudioLog(
"tryResume:context-not-running",
"audio-controller:tryResume",
this.audio,
{ state: ctxState },
);
if (this.currentSrc) {
this.emit("needs-resume");
}
return false;
}
try {
await this.audio.play();
this.startSilentPlaybackWatchdog();
return true;
} catch {
if (this.currentSrc) {
@@ -392,6 +484,7 @@ export class AudioController {
pause(): void {
this.autoResumeAfterRecovery = false;
this.cancelSilentPlaybackWatchdog();
this.audio.pause();
}
@@ -464,9 +557,12 @@ export class AudioController {
this.cancelNetworkRetry();
this.stopWatchdog();
this.cancelStallGrace();
this.cancelSilentPlaybackWatchdog();
this.currentSrc = src;
this.audio.src = src;
this.audio.play().catch((err) => {
this.audio.play().then(() => {
this.startSilentPlaybackWatchdog();
}).catch((err) => {
if (err instanceof DOMException && err.name === "NotAllowedError") {
this.emit("needs-resume");
return;
@@ -626,6 +722,7 @@ export class AudioController {
this.cancelNetworkRetry();
this.stopWatchdog();
this.cancelStallGrace();
this.cancelSilentPlaybackWatchdog();
this.clearReloadFailsafe();
this.audio.pause();
this.audio.removeAttribute("src");