fix: remove iOS auto-resume that caused earbud disconnect to route audio to speaker

The 1-second auto-resume in audio-controller.ts pause handler (added in b822375,
v1.7.6) could not distinguish between system pauses that warrant resume
(notification) and those that do not (audio route change when earbuds are
unplugged or Bluetooth disconnects). iOS fires an identical pause event for
both. When the timer fired play() after a route change, iOS routed audio to
the device loudspeaker -- startling the user mid-listen.

The original Control Center pause-then-play bug that b822375 targeted remained
reproducible, so the auto-resume was providing no benefit while introducing
this regression. MediaSession play action handler in useMediaSession.ts
already covers the Control Center path with a reloadAndPlay fallback gated on
a user gesture, which is the only safe way to resume on iOS.

Removes:
- interruptedWhilePlaying / interruptResumeTimeout / userInitiatedPause state
- wasInterrupted() / clearInterruptFlag() / cancelInterruptResume() methods
- 1-second setTimeout auto-resume in native pause handler
- wasInterrupted() branch in foreground visibility recovery
- clearInterruptFlag() call in MediaSession play action handler

Preserves all other recovery paths: network retry, stall watchdog,
AbortError -> reloadAndPlay, NotAllowedError -> needs-resume UI prompt,
visibility/pageshow foreground resume when element is already playing.
This commit is contained in:
chevron7
2026-04-16 22:18:56 -05:00
parent 22f6613f55
commit 7b41b91f11
3 changed files with 0 additions and 63 deletions
-3
View File
@@ -76,9 +76,6 @@ export function useMediaSession() {
}
}
// Clear interrupt flag — user is explicitly requesting play
controller.clearInterruptFlag();
try {
await controller.play();
} catch {
-51
View File
@@ -46,11 +46,6 @@ export class AudioController {
private readonly MAX_STALL_RECOVERIES = 3;
private autoResumeAfterRecovery = false;
// iOS interrupt tracking
private userInitiatedPause = false;
private interruptedWhilePlaying = false;
private interruptResumeTimeout: ReturnType<typeof setTimeout> | null = null;
// reloadAndPlay failsafe state (tracked for cleanup in destroy())
private reloadFailsafeTimeout: ReturnType<typeof setTimeout> | null = null;
private reloadFailsafeListener: (() => void) | null = null;
@@ -92,8 +87,6 @@ export class AudioController {
add("playing", () => {
this.networkRetryCount = 0;
this.stallRecoveryCount = 0;
this.interruptedWhilePlaying = false;
this.cancelInterruptResume();
this.startWatchdog();
this.cancelStallGrace();
this.emit("play");
@@ -101,25 +94,6 @@ export class AudioController {
add("pause", () => {
this.stopWatchdog();
if (!this.userInitiatedPause && this.currentSrc && !this.audio.ended) {
// System-initiated pause (iOS notification, phone call, control center)
this.interruptedWhilePlaying = true;
// Try auto-resume after a short delay (notification sounds are brief)
this.cancelInterruptResume();
this.interruptResumeTimeout = setTimeout(() => {
this.interruptResumeTimeout = null;
if (this.interruptedWhilePlaying && this.currentSrc) {
this.interruptedWhilePlaying = false;
this.play().catch(() => {
this.emit("needs-resume");
});
}
}, 1000);
}
this.userInitiatedPause = false;
this.emit("pause");
});
@@ -334,9 +308,6 @@ export class AudioController {
}
pause(): void {
this.userInitiatedPause = true;
this.interruptedWhilePlaying = false;
this.cancelInterruptResume();
this.autoResumeAfterRecovery = false;
this.audio.pause();
}
@@ -348,22 +319,6 @@ export class AudioController {
this.cancelStallGrace();
}
wasInterrupted(): boolean {
return this.interruptedWhilePlaying;
}
clearInterruptFlag(): void {
this.interruptedWhilePlaying = false;
this.cancelInterruptResume();
}
private cancelInterruptResume(): void {
if (this.interruptResumeTimeout) {
clearTimeout(this.interruptResumeTimeout);
this.interruptResumeTimeout = null;
}
}
private clearReloadFailsafe(): void {
if (this.reloadFailsafeTimeout) {
clearTimeout(this.reloadFailsafeTimeout);
@@ -415,9 +370,6 @@ export class AudioController {
}
stop(): void {
this.userInitiatedPause = true;
this.interruptedWhilePlaying = false;
this.cancelInterruptResume();
this.audio.pause();
this.audio.currentTime = 0;
}
@@ -559,10 +511,7 @@ export class AudioController {
this.cancelNetworkRetry();
this.stopWatchdog();
this.cancelStallGrace();
this.cancelInterruptResume();
this.clearReloadFailsafe();
this.interruptedWhilePlaying = false;
this.userInitiatedPause = true; // prevent cleanup pause from triggering interrupt logic
this.audio.pause();
this.audio.removeAttribute("src");
this.audio.load();
-9
View File
@@ -1300,15 +1300,6 @@ export function AudioControlsProvider({ children }: { children: ReactNode }) {
if (ctrl.isPlaying()) {
ctrl.tryResume().catch(() => {});
} else if (ctrl.wasInterrupted()) {
// iOS interrupted playback (notification, phone call, etc.)
// The auto-resume timer in the controller may have already
// tried, but if the app was suspended it couldn't run.
// Try again now that we're back in foreground.
ctrl.clearInterruptFlag();
ctrl.play().catch(() => {
ctrl.reloadAndPlay();
});
}
}
}