Skip to content

Commit 95196c6

Browse files
committed
improve mobile unlock code and chapter to readme
1 parent db3048a commit 95196c6

File tree

9 files changed

+126
-36
lines changed

9 files changed

+126
-36
lines changed

README.md

+32-1
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,7 @@ Note: if you use typescript, import the **ICoreOptions** interface along with th
222222
* playNextOnEnded: [boolean] (default: true) when a sound or song finishes playing should the player play the next sound that is in the queue or just stop playing
223223
* stopOnReset: [boolean] (default: true) when the queue gets reset and a sound is currently being played, should the player stop or continue playing that sound
224224
* visibilityAutoMute: [boolean] (default: false) tells the player if a sound is playing and the visibility API triggers a visibility change event, if the currently playing sound should get muted or not, uses the [Page Visibility API](https://developer.mozilla.org/en-US/docs/Web/API/Page_Visibility_API) internally
225-
* createAudioContextOnFirstUserInteraction: [boolean] (default: false) for a sound to be played the player needs to have an audiocontext, on mobile you can NOT play sounds / songs until the user has interacted in some way with your UI, this means autoplay with no user interaction will get prevented by the mobile browser (iOS (iPhone) and android mobile devices, today iPad is considered a desktop device and does not need this), when this option is set to true the player will try to catch the very first user interaction and initialize and audiocontext so that when a sound needs to be played the context will be available, there is a second option called "unLockAudioOnFirstPlay" which will do the same thing but instead of doing it on the first user interaction it will do it on the first call to play(), this is the preferred method as it keeps the audioContext turned of until it is really needed, which is preferrable to save resources on mobile
225+
* unlockAudioOnFirstUserInteraction: [boolean] (default: false) this tells the player to attempt to unlock audio as soon as possible, so that you can call the player play() method programmatically at any time, for more about this check out the chapter ["locked audio on mobile"](#locked-audio-on-mobile), if you don't want to the player to handle this part and prefer to do it manually then you can use the [player function](#player-functions) called **manuallyUnlockAudio()**
226226
* persistVolume: [boolean] (default: true) if this value is set to true the player will use the localstorage of the browser and save the value of the volume (localstorage entry key is **WebAudioAPIPlayerVolume**), if the page gets reloaded or the user comes back later the player will check if there is a value in the localstorage and automatically set the player volume to that value
227227
* loadPlayerMode: [typePlayerModes] (default: PLAYER_MODE_AUDIO) this is a constant you can import from player, currently you can chose between two modes, [PLAYER_MODE_AUDIO](#player_mode_audio) which uses the audio element to load sounds via the audio element and [PLAYER_MODE_AJAX](#player_mode_ajax) to load sounds via the web audio API, for more info about the modes read the [modes extended knowledge](#modes-extended-knowledge) chapter
228228
* audioContext: [AudioContext] (default: null) a custom [audiocontext](https://developer.mozilla.org/en-US/docs/Web/API/AudioContext) you inject to replace the default audiocontext of the player
@@ -330,6 +330,37 @@ player.addSoundToQueue({ soundAttributes: mySoundAttributes })
330330
* getVisibilityAutoMute() get the current boolean value that is set for the **visibilityAutoMute** option
331331
* disconnect() disconnects the player and destroys all songs in the queue, this function should get called for example in react when a component unmounts, call this function when you don't need the player anymore to free memory
332332
* getAudioContext() get the current audioContext that is being used by the player [MDN audiocontext](https://developer.mozilla.org/en-US/docs/Web/API/AudioContext)
333+
* manuallyUnlockAudio() this method can be used on mobile to unlock audio, because by default audio won't play on mobile if the code that triggers the player play() method is not a user interaction, for example a user pressing a play button works fine, but if you want to play a sound programmatically without any user interaction then the mobile browser will throw a notallowed error, this method can be used to unlock audio on a user interaction, after audio is unlocked you can use the play() method programmatically, an alternative if you don't want to implement this yourself is to enable the [player option](#player-options) called **unlockAudioOnFirstUserInteraction**, for more about this check out the chapter ["locked audio on mobile"](#locked-audio-on-mobile)
334+
* checkIfAudioIsUnlocked() function you can use to check if audio is unlocked on mobile, for example after calling manuallyUnlockAudio()
335+
336+
### locked audio on mobile
337+
338+
On mobile you can NOT play sounds (songs) programmatically until the user has interacted with the website / webapp
339+
340+
If the user clicks on a play button and in your event handler you call the play method of the player then everything is fine as it is a user interaction that triggered the play method
341+
342+
However if you want to play music programmatically on mobile, then the page / app needs to be "user activated", in other words on mobile when a page has finished loading audio is still locked you will not be able to programmatically play a sound (song), the play method will return a **NotAllowedError** error:
343+
344+
> The request is not allowed by the user agent or the platform in the current context, possibly because the user denied permission (No legacy code value and constant name).
345+
346+
This means that autoplay without a prior user interaction will get prevented by the mobile browsers
347+
348+
iOS (iPhone) and android mobile devices will throw an error, in the past iPad tablets would throw an error too, however newer versions are considered a desktop device and do not need throw an error
349+
350+
The process of unlocking audio on a mobile browser is called "Transient activation" and the player can help you achieve this if you don't want to write your own code
351+
352+
* solution 1: there is a [player option](#player-options) called **unlockAudioOnFirstUserInteraction**, set it to true when initializing the player and the player will add user interaction listeners to the html document, on the first user interaction the player will attempt to unlock audio, after audio is unlocked you will be able to call player play() function programmatically and it will not throw an error anywore
353+
354+
* solution 2: there is a [player function](#player-functions) called **manuallyUnlockAudio()** that you can use to attempt to unlock audio on mobile, this function MUST be played inside an event handler for a user interaction like "keydown" (excluding the Escape key and possibly some keys reserved by the browser or OS), "mousedown", "pointerdown" or "pointerup" (but only if the pointerType is "mouse") and "touchend"
355+
356+
After audio got unlocked, you can use the [player function](#player-functions) called **checkIfAudioIsUnlocked()** to check if now audio is unlocked, this is also useful because the browsers have an internal timer that starts running after "Transient activation" happend, so if the website (app) is idle for a while the browser might lock audio again, meaning you need to re-unlock it again, what the exact duration of that timer is depends on the browser and is not something browser vendors make public (in might also be different depending on the version of the browser)
357+
358+
Read more:
359+
360+
* [MDN: Features gated by user activation & Transient activation](https://developer.mozilla.org/en-US/docs/Web/Security/User_activation)
361+
* [WebKit: The User Activation API](https://webkit.org/blog/13862/the-user-activation-api/)
362+
* [MDN: Navigator: userActivation property](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/userActivation)
363+
* [caniuse: Navigator API: userActivation](https://caniuse.com/mdn-api_navigator_useractivation)
333364

334365
### modes extended knowledge
335366

dist/index.js

+41-12
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist/index.js.map

+1-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist/index.min.js

+1-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist/index.min.js.map

+1-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist/library/audio.d.ts

+2
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export declare class PlayerAudio {
3131
protected _audioElement: HTMLAudioElement;
3232
protected _mediaElementAudioSourceNode: MediaElementAudioSourceNode;
3333
protected _isAudioUnlocked: boolean;
34+
protected _isAudioUnlocking: boolean;
3435
constructor(options: IAudioOptions);
3536
protected _initialize(): void;
3637
getAudioNodes(): IAudioNodes;
@@ -39,6 +40,7 @@ export declare class PlayerAudio {
3940
protected _addFirstUserInteractionEventListeners(): void;
4041
protected _removeFirstUserInteractionEventListeners(): void;
4142
unlockAudio(): Promise<void>;
43+
isAudioUnlocked(): boolean;
4244
protected _createAudioElementAndSource(): Promise<void>;
4345
protected _createAudioElement(): Promise<void>;
4446
getAudioElement(): Promise<HTMLAudioElement>;

dist/library/core.d.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ export interface ICoreOptions {
2121
loadPlayerMode?: typePlayerMode;
2222
audioContext?: AudioContext;
2323
addAudioElementsToDom?: boolean;
24-
unLockAudioOnFirstPlay?: boolean;
2524
}
2625
export interface ISoundsQueueOptions {
2726
soundAttributes: ISoundAttributes;
@@ -85,6 +84,8 @@ export declare class PlayerCore {
8584
protected _loadSoundUsingAudioElement(sound: ISound): Promise<void>;
8685
protected _loadSoundUsingRequest(sound: ISound): Promise<void>;
8786
protected _decodeSound({ sound }: IDecodeSoundOptions): Promise<void>;
87+
manuallyUnlockAudio(): Promise<void>;
88+
checkIfAudioIsUnlocked(): Promise<boolean>;
8889
play({ whichSound, playTimeOffset }?: IPlayOptions): Promise<ISound>;
8990
protected _play(sound: ISound): Promise<void>;
9091
protected _playAudioBuffer(sound: ISound): Promise<void>;

src/library/audio.ts

+37-8
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export class PlayerAudio {
5252
protected _audioElement: HTMLAudioElement = null;
5353
protected _mediaElementAudioSourceNode: MediaElementAudioSourceNode = null;
5454
protected _isAudioUnlocked: boolean = false;
55+
protected _isAudioUnlocking: boolean = false;
5556

5657
constructor(options: IAudioOptions) {
5758

@@ -114,19 +115,23 @@ export class PlayerAudio {
114115
protected _addFirstUserInteractionEventListeners(): void {
115116

116117
if (this._options.createAudioContextOnFirstUserInteraction) {
117-
document.addEventListener('touchstart', this.unlockAudio.bind(this));
118-
document.addEventListener('touchend', this.unlockAudio.bind(this));
118+
document.addEventListener('keydown', this.unlockAudio.bind(this));
119119
document.addEventListener('mousedown', this.unlockAudio.bind(this));
120+
document.addEventListener('pointerdown', this.unlockAudio.bind(this));
121+
document.addEventListener('pointerup', this.unlockAudio.bind(this));
122+
document.addEventListener('touchend', this.unlockAudio.bind(this));
120123
}
121124

122125
}
123126

124127
protected _removeFirstUserInteractionEventListeners(): void {
125128

126129
if (this._options.createAudioContextOnFirstUserInteraction) {
127-
document.removeEventListener('touchstart', this.unlockAudio.bind(this));
128-
document.removeEventListener('touchend', this.unlockAudio.bind(this));
130+
document.removeEventListener('keydown', this.unlockAudio.bind(this));
129131
document.removeEventListener('mousedown', this.unlockAudio.bind(this));
132+
document.removeEventListener('pointerdown', this.unlockAudio.bind(this));
133+
document.removeEventListener('pointerup', this.unlockAudio.bind(this));
134+
document.removeEventListener('touchend', this.unlockAudio.bind(this));
130135
}
131136

132137
}
@@ -135,10 +140,17 @@ export class PlayerAudio {
135140

136141
return new Promise((resolve, reject) => {
137142

138-
if (this._isAudioUnlocked) {
143+
if (this._isAudioUnlocked || this._isAudioUnlocking) {
144+
return resolve();
145+
}
146+
147+
// https://webkit.org/blog/13862/the-user-activation-api/
148+
if (typeof navigator.userActivation !== 'undefined' && navigator.userActivation.isActive) {
139149
return resolve();
140150
}
141151

152+
this._isAudioUnlocking = true;
153+
142154
// make sure the audio context is not suspended
143155
// on android this is what unlocks audio
144156
this.getAudioContext().then(() => {
@@ -166,28 +178,43 @@ export class PlayerAudio {
166178
// as a direct result of an user interaction
167179
// after it got unlocked we re-use that element for all sounds
168180
this._createAudioElementAndSource().then(() => {
169-
170181
this._isAudioUnlocked = true;
182+
this._isAudioUnlocking = false;
171183
return resolve();
172-
}).catch(reject);
184+
}).catch((error) => {
185+
console.error(error);
186+
this._isAudioUnlocking = false;
187+
return reject();
188+
});
173189

174190
} else if (this._options.loadPlayerMode === 'player_mode_ajax') {
175191
this._isAudioUnlocked = true;
192+
this._isAudioUnlocking = false;
176193
return resolve();
177194
}
178195

179196
};
180197

181198
bufferSource.buffer = placeholderBuffer;
182199
bufferSource.connect(this._audioContext.destination);
200+
// attempt to play the empty buffer to check if there is an error
201+
// or if it can be played, in which case audio is unlocked
183202
bufferSource.start(0);
184203

185-
}).catch(reject);
204+
}).catch((error) => {
205+
console.error(error);
206+
this._isAudioUnlocking = false;
207+
return reject();
208+
});
186209

187210
});
188211

189212
}
190213

214+
public isAudioUnlocked() {
215+
return (this._isAudioUnlocked || (typeof navigator.userActivation !== 'undefined' && navigator.userActivation.isActive)) ? true : false;
216+
}
217+
191218
protected async _createAudioElementAndSource(): Promise<void> {
192219

193220
await this._createAudioElement();
@@ -447,6 +474,8 @@ export class PlayerAudio {
447474

448475
} else if (this._options.loadPlayerMode === 'player_mode_audio') {
449476

477+
await this._createAudioElementAndSource();
478+
450479
// create the sound gain node
451480
sound.gainNode = this._mediaElementAudioSourceNode.context.createGain();
452481

0 commit comments

Comments
 (0)