diff --git a/src/__tests__/sound/audio-context.test.ts b/src/__tests__/sound/audio-context.test.ts new file mode 100644 index 000000000..0baf1e40b --- /dev/null +++ b/src/__tests__/sound/audio-context.test.ts @@ -0,0 +1,106 @@ +import { expect, describe, test, jest } from '@jest/globals'; +import { + GESTURE_EVENTS, + resolveAudioContextCtor, + unlockAudioContextOnGesture, +} from '../../sound/audio-context'; + +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +function makeTarget() { + const listeners = new Map void>>(); + + return { + addEventListener(type: string, listener: () => void) { + if (!listeners.has(type)) { + listeners.set(type, new Set()); + } + + listeners.get(type).add(listener); + }, + removeEventListener(type: string, listener: () => void) { + listeners.get(type)?.delete(listener); + }, + fire(type: string) { + [...(listeners.get(type) ?? [])].forEach((listener) => listener()); + }, + count() { + return [...listeners.values()].reduce((total, set) => total + set.size, 0); + }, + }; +} + +describe('resolveAudioContextCtor', () => { + test('prefers the unprefixed constructor', () => { + const AudioContextCtor = function () {} as unknown as typeof AudioContext; + const webkitAudioContext = function () {} as unknown as typeof AudioContext; + + expect(resolveAudioContextCtor({ AudioContext: AudioContextCtor, webkitAudioContext })).toBe( + AudioContextCtor, + ); + }); + + test('falls back to the webkit constructor without touching a missing global', () => { + // Safari builds that only ship webkitAudioContext have no AudioContext + // binding at all, so this must resolve by property lookup and not throw. + const webkitAudioContext = function () {} as unknown as typeof AudioContext; + + expect(resolveAudioContextCtor({ webkitAudioContext })).toBe(webkitAudioContext); + }); + + test('returns null when neither constructor exists', () => { + expect(resolveAudioContextCtor({})).toBeNull(); + }); +}); + +describe('unlockAudioContextOnGesture', () => { + test('does not listen when the context is already running', () => { + const target = makeTarget(); + const resume = jest.fn(() => Promise.resolve()); + + unlockAudioContextOnGesture({ state: 'running', resume }, target); + + expect(target.count()).toBe(0); + expect(resume).not.toHaveBeenCalled(); + }); + + test('resumes on the first gesture and then stops listening', async () => { + const target = makeTarget(); + const resume = jest.fn(() => Promise.resolve()); + + unlockAudioContextOnGesture({ state: 'suspended', resume }, target); + expect(target.count()).toBe(GESTURE_EVENTS.length); + + target.fire('pointerdown'); + expect(resume).toHaveBeenCalledTimes(1); + + await flush(); + expect(target.count()).toBe(0); + }); + + test('stays armed when the resume is still refused', async () => { + const target = makeTarget(); + const resume = jest.fn(() => Promise.reject(new Error('blocked'))); + + unlockAudioContextOnGesture({ state: 'suspended', resume }, target); + + target.fire('pointerdown'); + await flush(); + + expect(target.count()).toBe(GESTURE_EVENTS.length); + + target.fire('keydown'); + expect(resume).toHaveBeenCalledTimes(2); + }); + + test('the returned canceller removes every listener', () => { + const target = makeTarget(); + const resume = jest.fn(() => Promise.resolve()); + + const cancel = unlockAudioContextOnGesture({ state: 'suspended', resume }, target); + expect(target.count()).toBe(GESTURE_EVENTS.length); + + cancel(); + expect(target.count()).toBe(0); + }); +}); diff --git a/src/sound/audio-context.ts b/src/sound/audio-context.ts new file mode 100644 index 000000000..e33bf0d77 --- /dev/null +++ b/src/sound/audio-context.ts @@ -0,0 +1,73 @@ +/** + * WebKit - every browser on iOS, plus desktop Safari - hands back an + * AudioContext in the "suspended" state unless it was constructed inside a user + * gesture, and it only leaves that state when something calls resume() from + * inside one. SoundSys builds its context while the page is still loading, so + * without an explicit unlock every effect stays silent for the whole session on + * those browsers. + */ + +type AudioContextCtor = typeof AudioContext; + +type AudioContextWindow = { + AudioContext?: AudioContextCtor; + webkitAudioContext?: AudioContextCtor; +}; + +type ResumableContext = Pick; + +type GestureTarget = { + addEventListener(type: string, listener: () => void): void; + removeEventListener(type: string, listener: () => void): void; +}; + +/** The interactions WebKit accepts as a gesture for unlocking audio. */ +export const GESTURE_EVENTS = ['pointerdown', 'touchend', 'keydown']; + +/** + * Resolves the AudioContext constructor from a window object. + * + * Reading it as a property matters. Safari builds that only ship + * `webkitAudioContext` have no global binding named `AudioContext` at all, so an + * unqualified reference throws a ReferenceError instead of evaluating to + * undefined and falling through to the prefixed constructor. + */ +export function resolveAudioContextCtor(win?: AudioContextWindow): AudioContextCtor | null { + const target = win ?? (typeof window === 'undefined' ? undefined : window); + + if (!target) { + return null; + } + + return target.AudioContext ?? target.webkitAudioContext ?? null; +} + +/** + * Resumes a suspended AudioContext on the first user gesture, then stops + * listening. If the resume is still refused the listeners stay armed for the + * next gesture. Returns a function that cancels the pending unlock. + */ +export function unlockAudioContextOnGesture( + context: ResumableContext, + target?: GestureTarget, +): () => void { + const listenTarget = target ?? (typeof document === 'undefined' ? undefined : document); + + if (!listenTarget || context.state === 'running') { + return () => undefined; + } + + function stop() { + GESTURE_EVENTS.forEach((event) => listenTarget.removeEventListener(event, unlock)); + } + + function unlock() { + Promise.resolve(context.resume()).then(stop, () => { + // Still refused - leave the listeners armed for the next gesture. + }); + } + + GESTURE_EVENTS.forEach((event) => listenTarget.addEventListener(event, unlock)); + + return stop; +} diff --git a/src/sound/musicplayer.js b/src/sound/musicplayer.js index 92414388f..3e329ad46 100644 --- a/src/sound/musicplayer.js +++ b/src/sound/musicplayer.js @@ -1,6 +1,7 @@ import * as $j from 'jquery'; import skin from './skin'; import beastAudioFile from 'assets/sounds/AncientBeast.ogg'; +import { GESTURE_EVENTS } from './audio-context'; export class MusicPlayer { constructor() { @@ -9,6 +10,7 @@ export class MusicPlayer { this.tracks = this.playlist.find('li.epic'); this.repeat = true; + this._gestureRetry = null; this.audio.volume = 0.25; this.audio.pause(); @@ -161,15 +163,54 @@ export class MusicPlayer { } // Play audio + this.cancelGestureRetry(); this.audio.play().catch((error) => { if (error?.name === 'AbortError') { return; } + if (error?.name === 'NotAllowedError') { + // Safari and iOS refuse playback that didn't start from a user + // gesture. Retry on the next one instead of staying silent. + this.playOnNextGesture(); + return; + } console.error('Error playing audio:', error); }); } + /** + * Arms a one-shot retry so a track blocked by the autoplay policy starts + * on the next user interaction. + */ + playOnNextGesture() { + if (this._gestureRetry) { + return; + } + + const retry = () => { + this.audio.play().then( + () => this.cancelGestureRetry(), + () => { + // Still refused - stay armed for the next gesture. + }, + ); + }; + + this._gestureRetry = retry; + GESTURE_EVENTS.forEach((event) => document.addEventListener(event, retry)); + } + + cancelGestureRetry() { + if (!this._gestureRetry) { + return; + } + + GESTURE_EVENTS.forEach((event) => document.removeEventListener(event, this._gestureRetry)); + this._gestureRetry = null; + } + stopMusic() { + this.cancelGestureRetry(); this.audio.pause(); } } diff --git a/src/sound/soundsys.ts b/src/sound/soundsys.ts index 7404115cd..3b295992b 100644 --- a/src/sound/soundsys.ts +++ b/src/sound/soundsys.ts @@ -2,6 +2,7 @@ import { BufferLoader } from './bufferloader'; import { getUrl } from '../assets'; import { MusicPlayer } from './musicplayer'; import { clamp } from '../utility/math'; +import { resolveAudioContextCtor, unlockAudioContextOnGesture } from './audio-context'; export type AudioMode = 'full' | 'sfx' | 'muted'; let currentAudioMode: AudioMode = 'full'; @@ -21,7 +22,7 @@ type SoundSysConfig = { export class SoundSys { musicPlayer: MusicPlayer; - private envHasSound = window && ('AudioContext' in window || 'webkitAudioContext' in window); + private envHasSound = resolveAudioContextCtor() !== null; private context: AudioContext; private loadedPaths: Record = {}; @@ -39,9 +40,13 @@ export class SoundSys { constructor(config: SoundSysConfig) { this.musicPlayer = new MusicPlayer(); - if (this.envHasSound) { - this.context = new (AudioContext || - (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)(); + const AudioContextCtor = resolveAudioContextCtor(); + + if (this.envHasSound && AudioContextCtor) { + this.context = new AudioContextCtor(); + // WebKit suspends a context that wasn't created inside a user gesture, + // so resume it on the first interaction or every effect stays silent. + unlockAudioContextOnGesture(this.context); this.musicGainNode = this.context.createGain(); this.musicGainNode.connect(this.context.destination); if ('musicVolume' in config) {