Skip to content
Merged
36 changes: 36 additions & 0 deletions packages/core/src/Polyfill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export class Polyfill {
static registerPolyfill(): void {
Polyfill._registerMatchAll();
Polyfill._registerAudioContext();
Polyfill._registerOfflineAudioContext();
Polyfill._registerTextMetrics();
Polyfill._registerPromiseFinally();
}
Expand Down Expand Up @@ -72,6 +73,41 @@ export class Polyfill {
}
}

private static _registerOfflineAudioContext(): void {
// iOS 14.0 and earlier expose only webkitOfflineAudioContext, with callback-form decodeAudioData
if (!window.OfflineAudioContext && (window as any).webkitOfflineAudioContext) {
Logger.info("Polyfill window.OfflineAudioContext");
window.OfflineAudioContext = (window as any).webkitOfflineAudioContext;

const originalDecodeAudioData = OfflineAudioContext.prototype.decodeAudioData as (
audioData: ArrayBuffer,
successCallback?: DecodeSuccessCallback | null,
errorCallback?: DecodeErrorCallback | null
) => void;

OfflineAudioContext.prototype.decodeAudioData = function (
arrayBuffer: ArrayBuffer,
successCallback?: DecodeSuccessCallback | null,
errorCallback?: DecodeErrorCallback | null
): Promise<AudioBuffer> {
return new Promise<AudioBuffer>((resolve, reject) => {
originalDecodeAudioData.call(
this,
arrayBuffer,
(buffer: AudioBuffer) => {
successCallback?.(buffer);
resolve(buffer);
},
(error: DOMException) => {
errorCallback?.(error);
reject(error);
}
);
});
};
}
}

private static _registerTextMetrics(): void {
// Based on the specific version of the engine implementation, when actualBoundingBoxLeft is not supported, width is used to represent the rendering width, and `textAlign` uses the default value `start` and direction is left to right.
// Some devices do not support actualBoundingBoxLeft and actualBoundingBoxRight in TextMetrics.
Expand Down
178 changes: 61 additions & 117 deletions packages/core/src/audio/AudioManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,28 @@
* Audio Manager for managing global audio context and settings.
*/
export class AudioManager {
/** @internal */
static _playingCount = 0;

private static _context: AudioContext;
private static _gainNode: GainNode;
private static _resumePromise: Promise<void> = null;
private static _needsUserGestureResume = false;
private static _hidden = false;
private static _foregroundResumeTimer: ReturnType<typeof setTimeout> | null = null;
private static _suspendedByCaller = false;
private static _recovering = false;

/**
* Suspend the audio context.
* @returns A promise that resolves when the audio context is suspended
*/
static suspend(): Promise<void> {
// No context means nothing is playing: suspending is a no-op and must NOT flag a caller-suspend
// (a ghost flag would later block foreground recovery), and don't create a cold context just to suspend
const context = AudioManager._context;
if (!context) {
return Promise.resolve();
}
AudioManager._suspendedByCaller = true;
AudioManager._needsUserGestureResume = false;
AudioManager._clearForegroundResumeTimer();
AudioManager._removeGestureListeners();
return context.suspend();
}

Expand All @@ -32,25 +34,14 @@ export class AudioManager {
*/
static resume(): Promise<void> {
AudioManager._suspendedByCaller = false;
AudioManager._clearForegroundResumeTimer();
if (document.hidden) {
AudioManager._onHidden();
}

if (AudioManager._hidden) {
return Promise.resolve();
}

const context = AudioManager.getContext();
if (context.state === "running") {
AudioManager._needsUserGestureResume = false;
AudioManager._removeGestureListeners();
return Promise.resolve();
}
return context.resume().then(() => {
AudioManager._needsUserGestureResume = false;
AudioManager._removeGestureListeners();
});
return (AudioManager._resumePromise ??= AudioManager.getContext()
.resume()
.then(() => {
AudioManager._needsUserGestureResume = false;
})
.finally(() => {
AudioManager._resumePromise = null;
}));
}

/**
Expand All @@ -60,11 +51,13 @@ export class AudioManager {
let context = AudioManager._context;
if (!context) {
AudioManager._context = context = new window.AudioContext();
AudioManager._hidden = document.hidden;
context.onstatechange = AudioManager._onContextStateChange;
document.addEventListener("visibilitychange", AudioManager._onVisibilityChange);
window.addEventListener("pagehide", AudioManager._onHidden);
window.addEventListener("pageshow", AudioManager._onShown);
// iOS Safari bfcache restore fires pageshow (persisted) but NOT visibilitychange, so recover here too
window.addEventListener("pageshow", AudioManager._onPageShow);
// iOS Safari requires a user gesture to resume the AudioContext
document.addEventListener("touchstart", AudioManager._resumeAfterInterruption, { passive: true });
document.addEventListener("touchend", AudioManager._resumeAfterInterruption, { passive: true });
document.addEventListener("click", AudioManager._resumeAfterInterruption);
}
return context;
}
Expand All @@ -89,111 +82,62 @@ export class AudioManager {
return AudioManager.getContext().state === "running";
}

/**
* @internal
*/
static _canStartPlayback(): boolean {
private static _onVisibilityChange(): void {
if (document.hidden) {
AudioManager._onHidden();
return false;
}
if (AudioManager._hidden) {
return false;
// Desktop/Android don't auto-suspend a running WebAudio context when backgrounded (only iOS does),
// so suspend here to stop audio in the background; only if a context already exists (don't create one)
AudioManager._context?.suspend().catch(() => {});
} else {
AudioManager._recoverPlaybackContext();
}
return AudioManager.getContext().state === "running";
}

private static _onContextStateChange(): void {
const state = AudioManager._context?.state;
if (state === "running" && !AudioManager._hidden) {
AudioManager._suspendedByCaller = false;
AudioManager._needsUserGestureResume = false;
AudioManager._removeGestureListeners();
} else if (
state &&
state !== "running" &&
!AudioManager._hidden &&
!AudioManager._suspendedByCaller &&
!AudioManager._foregroundResumeTimer
private static _recoverPlaybackContext(): void {
// Returning to foreground with a non-running context (and not a deliberate pause): iOS leaves it
// "interrupted", which cannot be resumed directly; suspend() first transitions it to "suspended",
// then resume() restarts the pipeline https://bugs.webkit.org/show_bug.cgi?id=263627
// _recovering guards re-entry: a bfcache restore fires both visibilitychange and pageshow
if (
AudioManager._recovering ||
document.hidden ||
AudioManager._suspendedByCaller ||
AudioManager._playingCount <= 0 ||
AudioManager.isAudioContextRunning()
) {
AudioManager._needsUserGestureResume = true;
AudioManager._addGestureListeners();
}
}

private static _onVisibilityChange(): void {
document.hidden ? AudioManager._onHidden() : AudioManager._onShown();
}

private static _onHidden(): void {
const context = AudioManager._context;
if (AudioManager._hidden && context?.state !== "running") {
return;
}
AudioManager._hidden = true;
AudioManager._clearForegroundResumeTimer();
context?.suspend().catch(() => {});
}

private static _onShown(): void {
if (!AudioManager._hidden) {
return;
}
AudioManager._hidden = false;

const context = AudioManager._context;
if (!context || AudioManager._suspendedByCaller) {
return;
}
const isForegroundResumeStale = (): boolean =>
AudioManager._hidden || AudioManager._suspendedByCaller || AudioManager._context !== context;
// iOS WKWebView zombie fix (https://bugs.webkit.org/show_bug.cgi?id=263627):
// force suspend then resume after a short delay to reset the audio rendering pipeline.
AudioManager._recovering = true;
AudioManager._needsUserGestureResume = true; // fallback if the auto-resume below is rejected
const context = AudioManager.getContext();
context.suspend().catch(() => {});
AudioManager._foregroundResumeTimer = setTimeout(() => {
AudioManager._foregroundResumeTimer = null;
if (isForegroundResumeStale()) {
// 100ms empirical delay (resume too soon after suspend is unreliable on iOS); _recovering is cleared
// on the timer rather than off a promise because iOS may never settle suspend/resume in interrupted
setTimeout(() => {
AudioManager._recovering = false;
if (document.hidden || AudioManager._suspendedByCaller) {
return;
}
context
.resume()
.then(() => {
if (isForegroundResumeStale()) {
return;
}
AudioManager._needsUserGestureResume = false;
AudioManager._removeGestureListeners();
})
.catch(() => {
if (isForegroundResumeStale()) {
return;
}
AudioManager._needsUserGestureResume = true;
AudioManager._addGestureListeners();
});
// Go through AudioManager.resume() so _resumePromise coalesces any gesture-resume racing us during
// the slow iOS interrupted->running transition; a bare context.resume() here wouldn't dedupe
AudioManager.resume().catch(() => {});
}, 100);
}

private static _resumeAfterInterruption(): void {
if (!AudioManager._suspendedByCaller && AudioManager._needsUserGestureResume) {
AudioManager.resume().catch(() => {});
private static _onPageShow(event: PageTransitionEvent): void {
// iOS Safari bfcache restore (persisted) needs recovery; a normal load has no suspended context
if (event.persisted) {
AudioManager._recoverPlaybackContext();
}
}

private static _clearForegroundResumeTimer(): void {
if (AudioManager._foregroundResumeTimer !== null) {
clearTimeout(AudioManager._foregroundResumeTimer);
AudioManager._foregroundResumeTimer = null;
private static _resumeAfterInterruption(): void {
// iOS Safari gesture fallback for when auto-resume is blocked.
// _recovering: don't bypass the 100ms delay (would resume on a still-interrupted context)
if (AudioManager._recovering || AudioManager._suspendedByCaller || !AudioManager._needsUserGestureResume) {
return;
}
}

private static _addGestureListeners(): void {
document.addEventListener("pointerup", AudioManager._resumeAfterInterruption, { passive: true });
document.addEventListener("click", AudioManager._resumeAfterInterruption, { passive: true });
}

private static _removeGestureListeners(): void {
document.removeEventListener("pointerup", AudioManager._resumeAfterInterruption);
document.removeEventListener("click", AudioManager._resumeAfterInterruption);
AudioManager.resume().catch((e) => {
console.warn("Failed to resume AudioContext:", e);
});
}
}
Loading
Loading