mirror of
https://github.com/Chevron7Locked/kima-hub.git
synced 2026-06-19 07:37:17 +00:00
fix(ios): bridge audio element through AudioContext to survive backgrounding
iOS WKWebView suspends the HTMLAudioElement's audio session when a standalone PWA is backgrounded (WebKit #261858). MediaSession reports "playing" but no sound. Routing the element through an AudioContext holds the iOS audio session more durably and survives backgrounding. Bridge is set up lazily on the first user-gesture play and only when the user-agent is iOS AND display-mode is standalone (i.e. the only case where the suspension bug applies). Desktop, Android, and iOS Safari tabs keep the bare-element path.
This commit is contained in:
@@ -55,6 +55,16 @@ export class AudioController {
|
||||
// Route-change observability: track time of last play to compute ms-since-play on pause.
|
||||
private lastPlayAt = 0;
|
||||
|
||||
// iOS standalone PWA audio session bridge. iOS WKWebView suspends the
|
||||
// HTMLAudioElement's audio session when backgrounded; MediaSession reports
|
||||
// "playing" but no sound. Routing the element through an AudioContext
|
||||
// keeps the iOS audio session active across backgrounding because the
|
||||
// AudioContext claims it more durably than a bare <audio>.
|
||||
// WebKit #261858. Bridge set up lazily on the first user-gesture play.
|
||||
private audioContext: AudioContext | null = null;
|
||||
private mediaSourceNode: MediaElementAudioSourceNode | null = null;
|
||||
private audioContextBridgeAttempted = false;
|
||||
|
||||
constructor(audio: HTMLAudioElement) {
|
||||
this.audio = audio;
|
||||
this.audio.preload = "auto";
|
||||
@@ -83,6 +93,50 @@ export class AudioController {
|
||||
}
|
||||
}
|
||||
|
||||
private isIosStandalone(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
try {
|
||||
const isIos = /iPhone|iPad|iPod/.test(navigator.userAgent);
|
||||
if (!isIos) return false;
|
||||
const legacy = (navigator as { standalone?: boolean }).standalone === true;
|
||||
const modern = window.matchMedia?.("(display-mode: standalone)").matches === true;
|
||||
return legacy || modern;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private setupAudioContextBridge(): void {
|
||||
if (this.audioContextBridgeAttempted) {
|
||||
// Already attempted; resume if suspended (idempotent, cheap).
|
||||
this.audioContext?.resume?.().catch(() => {});
|
||||
return;
|
||||
}
|
||||
if (!this.isIosStandalone()) return;
|
||||
this.audioContextBridgeAttempted = true;
|
||||
try {
|
||||
const AC = window.AudioContext || (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||||
if (!AC) return;
|
||||
this.audioContext = new AC();
|
||||
this.mediaSourceNode = this.audioContext.createMediaElementSource(this.audio);
|
||||
this.mediaSourceNode.connect(this.audioContext.destination);
|
||||
this.audioContext.resume().catch(() => {});
|
||||
iosAudioLog(
|
||||
"audio-context:bridge-up",
|
||||
"audio-controller:setupAudioContextBridge",
|
||||
this.audio,
|
||||
{ state: this.audioContext.state },
|
||||
);
|
||||
} catch (err) {
|
||||
iosAudioLog(
|
||||
"audio-context:bridge-fail",
|
||||
"audio-controller:setupAudioContextBridge",
|
||||
this.audio,
|
||||
{ error: err instanceof Error ? err.message : String(err) },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private attachNativeListeners(): void {
|
||||
const add = (event: string, handler: EventListener) => {
|
||||
this.audio.addEventListener(event, handler);
|
||||
@@ -293,6 +347,7 @@ export class AudioController {
|
||||
if (!this.audio.src) return;
|
||||
|
||||
this.setAudioSessionPlayback();
|
||||
this.setupAudioContextBridge();
|
||||
|
||||
try {
|
||||
await this.audio.play();
|
||||
@@ -322,6 +377,7 @@ export class AudioController {
|
||||
if (!this.audio.paused) return true;
|
||||
|
||||
this.setAudioSessionPlayback();
|
||||
this.setupAudioContextBridge();
|
||||
|
||||
try {
|
||||
await this.audio.play();
|
||||
|
||||
Reference in New Issue
Block a user