mirror of
https://github.com/Chevron7Locked/kima-hub.git
synced 2026-06-19 07:37:17 +00:00
fix(ios): auto-resume audio after an OS interruption + repair the trace upload
Playback that an iOS interruption (call/notification) pauses now resumes when the interruption ends, the behaviour other apps have. - Track play intent separately from audio.paused: set on play/tryResume/ swapAndPlay, cleared only by explicit pause/stop/cleanup. The native "pause" event an interruption fires does NOT clear it. - The AudioContext statechange listener resumes on an interrupted -> running transition when intent is set and the element is paused. Gated hard: only that transition (not the initial bridge resume or a background suspend), never within 1.5s of an audio-route change (the v1.7.12 unplug-to-speaker regression), and never while a stall reload owns the resume. - Repair the trace auto-upload: it POSTed to a requireAuth route without the Bearer token and swallowed the 401, so no iOS trace was ever captured. It now sends the token, so device testing finally yields real event data. Reviewed by Opus/Sonnet passes. Known limits to confirm on-device: only fires if WebKit returns the context to "running" (a context stuck "interrupted" -- the force-quit symptom -- is not addressed here).
This commit is contained in:
@@ -65,6 +65,22 @@ export class AudioController {
|
||||
private mediaSourceNode: MediaElementAudioSourceNode | null = null;
|
||||
private audioContextBridgeAttempted = false;
|
||||
private audioContextStateHandler: (() => void) | null = null;
|
||||
// Play intent, distinct from audio.paused. True while the user wants playback
|
||||
// on; cleared ONLY by an explicit pause()/stop(). An OS interruption (call,
|
||||
// notification, another app) pauses the element via the native "pause" event
|
||||
// WITHOUT clearing this, so when the interruption ends we can tell a resume
|
||||
// apart from a deliberate pause.
|
||||
private intendsToPlay = false;
|
||||
// Last observed AudioContext state, so the resume below can require an
|
||||
// interrupted -> running transition specifically, not the initial bridge
|
||||
// resume (suspended -> running) or a background suspend.
|
||||
private lastContextState: string | undefined = undefined;
|
||||
// Timestamp of the last audio-route change (earbud/Bluetooth unplug). A resume
|
||||
// within this window of a route change is suppressed so unplugged audio is
|
||||
// never routed to the phone speaker (the v1.7.12 regression).
|
||||
private lastRouteChangeAt = 0;
|
||||
private readonly ROUTE_CHANGE_GUARD_MS = 1500;
|
||||
private routeChangeHandler: (() => void) | null = null;
|
||||
|
||||
constructor(audio: HTMLAudioElement) {
|
||||
this.audio = audio;
|
||||
@@ -127,23 +143,56 @@ export class AudioController {
|
||||
this.audioContext = new AC();
|
||||
this.audioContextStateHandler = () => {
|
||||
const state = this.audioContext?.state;
|
||||
const prev = this.lastContextState;
|
||||
this.lastContextState = state;
|
||||
const sinceRoute = Date.now() - this.lastRouteChangeAt;
|
||||
iosAudioLog(
|
||||
"audio-context:statechange",
|
||||
"audio-controller:setupAudioContextBridge",
|
||||
this.audio,
|
||||
{ state },
|
||||
{ state, prev, intendsToPlay: this.intendsToPlay, paused: this.audio.paused, sinceRouteMs: sinceRoute },
|
||||
);
|
||||
// OS ended an interruption and the context is live again: re-claim
|
||||
// the playback session category so iOS does not leave it assigned
|
||||
// to whatever app grabbed it during the interruption. Never call
|
||||
// play() here -- auto-resuming on a route/interruption change is the
|
||||
// v1.7.12 earbud-unplug-to-speaker regression. If the element was
|
||||
// never paused it resumes on its own once the context runs.
|
||||
if (state === "running" && !this.audio.paused) {
|
||||
this.setAudioSessionPlayback(true);
|
||||
if (state !== "running") return;
|
||||
// Session is live again: re-claim the playback category so iOS
|
||||
// does not leave it with whatever app grabbed it.
|
||||
this.setAudioSessionPlayback(true);
|
||||
// Auto-resume ONLY coming out of an OS interruption
|
||||
// (interrupted -> running). Excluding the initial bridge resume
|
||||
// (suspended -> running) and a background suspend avoids a
|
||||
// double-play race with the gesture path and the stall reload.
|
||||
if (prev !== "interrupted") return;
|
||||
// Never resume right after a route change (earbud/BT unplug): that
|
||||
// is the v1.7.12 speaker-routing regression. An interruption end is
|
||||
// not accompanied by a devicechange; an unplug is.
|
||||
if (sinceRoute < this.ROUTE_CHANGE_GUARD_MS) return;
|
||||
// A stall-recovery reload already owns the resume.
|
||||
if (this.autoResumeAfterRecovery) return;
|
||||
if (this.intendsToPlay && this.audio.paused) {
|
||||
iosAudioLog("audio-context:auto-resume", "audio-controller:setupAudioContextBridge", this.audio);
|
||||
this.audio.play().catch((err) => {
|
||||
iosAudioLog(
|
||||
"audio-context:auto-resume-failed",
|
||||
"audio-controller:setupAudioContextBridge",
|
||||
this.audio,
|
||||
{ error: err instanceof Error ? err.message : String(err) },
|
||||
);
|
||||
});
|
||||
}
|
||||
};
|
||||
this.audioContext.addEventListener("statechange", this.audioContextStateHandler);
|
||||
// Mark audio-route changes (earbud/BT unplug) so the resume above can
|
||||
// refuse to fire right after one.
|
||||
try {
|
||||
if (navigator.mediaDevices?.addEventListener) {
|
||||
this.routeChangeHandler = () => {
|
||||
this.lastRouteChangeAt = Date.now();
|
||||
iosAudioLog("route-change", "audio-controller:devicechange", this.audio);
|
||||
};
|
||||
navigator.mediaDevices.addEventListener("devicechange", this.routeChangeHandler);
|
||||
}
|
||||
} catch {
|
||||
// mediaDevices unavailable
|
||||
}
|
||||
this.mediaSourceNode = this.audioContext.createMediaElementSource(this.audio);
|
||||
this.mediaSourceNode.connect(this.audioContext.destination);
|
||||
this.audioContext.resume().catch(() => {});
|
||||
@@ -371,6 +420,7 @@ export class AudioController {
|
||||
async play(): Promise<void> {
|
||||
iosAudioLog("play:entry", "audio-controller:play", this.audio);
|
||||
if (!this.audio.src) return;
|
||||
this.intendsToPlay = true;
|
||||
|
||||
// Explicit play/resume: re-claim the session category every time (force),
|
||||
// not just on first play -- this is the path the MediaSession "play"
|
||||
@@ -393,6 +443,10 @@ export class AudioController {
|
||||
}
|
||||
if (err instanceof DOMException && err.name === "NotAllowedError") {
|
||||
iosAudioLog("play:not-allowed", "audio-controller:play", this.audio);
|
||||
// Intent can't be honoured without a fresh gesture -- clear it so
|
||||
// a later interruption-end doesn't auto-resume content the user
|
||||
// never actually got playing.
|
||||
this.intendsToPlay = false;
|
||||
// User gesture required -- emit needs-resume so UI can prompt
|
||||
this.emit("needs-resume");
|
||||
return;
|
||||
@@ -405,6 +459,7 @@ export class AudioController {
|
||||
async tryResume(): Promise<boolean> {
|
||||
if (!this.audio.src) return false;
|
||||
if (!this.audio.paused) return true;
|
||||
this.intendsToPlay = true;
|
||||
|
||||
this.setAudioSessionPlayback();
|
||||
this.setupAudioContextBridge();
|
||||
@@ -413,6 +468,7 @@ export class AudioController {
|
||||
await this.audio.play();
|
||||
return true;
|
||||
} catch {
|
||||
this.intendsToPlay = false;
|
||||
if (this.currentSrc) {
|
||||
this.emit("needs-resume");
|
||||
}
|
||||
@@ -421,6 +477,7 @@ export class AudioController {
|
||||
}
|
||||
|
||||
pause(): void {
|
||||
this.intendsToPlay = false;
|
||||
this.autoResumeAfterRecovery = false;
|
||||
this.audio.pause();
|
||||
}
|
||||
@@ -491,6 +548,7 @@ export class AudioController {
|
||||
*/
|
||||
swapAndPlay(src: string): void {
|
||||
iosAudioLog("swapAndPlay:entry", "audio-controller:swapAndPlay", this.audio, { src: src.slice(-40) });
|
||||
this.intendsToPlay = true;
|
||||
this.cancelNetworkRetry();
|
||||
this.stopWatchdog();
|
||||
this.cancelStallGrace();
|
||||
@@ -511,6 +569,7 @@ export class AudioController {
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.intendsToPlay = false;
|
||||
this.audio.pause();
|
||||
this.audio.currentTime = 0;
|
||||
}
|
||||
@@ -653,6 +712,7 @@ export class AudioController {
|
||||
}
|
||||
|
||||
cleanup(): void {
|
||||
this.intendsToPlay = false;
|
||||
this.cancelNetworkRetry();
|
||||
this.stopWatchdog();
|
||||
this.cancelStallGrace();
|
||||
@@ -672,6 +732,11 @@ export class AudioController {
|
||||
this.cleanup();
|
||||
this.detachNativeListeners();
|
||||
|
||||
if (this.routeChangeHandler && navigator.mediaDevices?.removeEventListener) {
|
||||
navigator.mediaDevices.removeEventListener("devicechange", this.routeChangeHandler);
|
||||
this.routeChangeHandler = null;
|
||||
}
|
||||
|
||||
if (this.audioContext) {
|
||||
if (this.audioContextStateHandler) {
|
||||
this.audioContext.removeEventListener("statechange", this.audioContextStateHandler);
|
||||
|
||||
@@ -97,9 +97,21 @@ function scheduleAutoUpload(): void {
|
||||
uploadTimer = setTimeout(() => {
|
||||
uploadTimer = null;
|
||||
try {
|
||||
// The /api/debug/ios-log route is requireAuth (Bearer), so the upload
|
||||
// MUST send the token the same way the api client does -- cookies
|
||||
// alone 401, and the .catch below would swallow it silently (which is
|
||||
// exactly why no trace was ever captured before).
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
let token: string | null = null;
|
||||
try {
|
||||
token = window.localStorage.getItem("auth_token");
|
||||
} catch {
|
||||
// localStorage unavailable
|
||||
}
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
void fetch("/api/debug/ios-log", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers,
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ events: buffer }),
|
||||
}).catch(() => {});
|
||||
|
||||
Reference in New Issue
Block a user