/**
* AngularJS Service for playing sounds using Howler.js
*
* @module soundService
* @see https://docs.angularjs.org/api/ng/type/angular.Module#service
*/
angular.module('tgaApp').service('soundService', [
'$window',
'$document',
'$log',
'localDataService',
'utilityService',
'$timeout',
function soundService($window, $document, $log, localDataService, utilityService, $timeout) {
/**
* Stored list of sounds, with sound ID and Howler object.
*
* @property {string} sounds[].id - A sound ID.
* @property {Object} sounds[].howl - A Howler sound object.
*/
this.sounds = [];
/**
* Stored list of paused sound IDs.
*/
this.pauseArray = [];
/**
* Find an object from {@link module:soundService.sounds}.
*
* @param {string} id - A sound ID.
* @return {?Object} Sound object if found.
*/
this.find = (id) => {
for (let i = 0; i < this.sounds.length; i++) {
if (this.sounds[i].id === id) {
return this.sounds[i];
}
}
return null;
};
/**
* Find an object from {@link module:soundService.sounds} and execute a callback with it.
*
* @param {string} id - A sound ID.
* @param {function} callback - A callback to run on the found audio object.
* @return {?*} The return value of the callback, if sound is found.
*/
this.findAndCall = (id, callback) => {
const sound = this.find(id);
if (sound) {
return callback(sound);
}
return null;
};
/**
* Play a sound randomly from a list.
*
* @param {string[]} - An array of sound IDs.
* @param {Object} [settings] - Sound settings to use (see {@link module:soundService.play}).
*/
this.playRandom = (idArr, settings) => {
const id = idArr[Math.floor(Math.random() * idArr.length)];
this.play(id, settings);
};
/**
* Play a specific sound.
*
* @param {string} id - A sound ID.
* @param {Object} [settings] - Sound settings to use.
* @param {boolean} [settings.loop] - Whether to loop the sound.
* @param {number} [settings.volume] - The sound volume (0.0-1.0).
* @param {function} [settings.onComplete] - Callback to run on sound end.
* @param {number} [settings.delay] - Delay before playing (milliseconds).
*/
this.play = (id, settings) => {
const s = settings ?? {};
this.findAndCall(id, (sound) => {
if (angular.isDefined(s.loop)) sound.howl.loop(s.loop);
if (angular.isNumber(s.volume)) sound.howl.volume(s.volume);
if (angular.isFunction(s.onComplete)) sound.howl.once('end', s.onComplete);
if (angular.isNumber(s.delay)) {
$timeout(() => {
sound.howl.play();
}, s.delay);
} else {
sound.howl.play();
}
});
};
/**
* Fade a specific sound.
*
* @param {string} id - A sound ID.
* @param {Object} [settings] - Sound settings to use.
* @param {number} [settings.from] - Initial sound volume (0.0-1.0).
* @param {number} [settings.to] - Final sound volume (0.0-1.0).
* @param {number} [settings.duration] - Length of fade (milliseconds).
* @param {function} [settings.onComplete] - Callback to run on fade end.
*/
this.fade = (id, settings) => {
const defaultSettings = {
from: 0,
to: 1,
duration: 1000,
onComplete: null,
};
const s = {
...defaultSettings,
...settings,
};
this.findAndCall(id, (sound) => {
sound.howl.fade(s.from, s.to, s.duration);
if (s.onComplete) {
sound.howl.once('fade', s.onComplete);
}
});
};
/**
* Check if a sound is playing.
*
* @param {string} id - A sound ID.
* @return {?boolean}
*/
this.isPlaying = (id) => this.findAndCall(id, (sound) => sound.howl.playing());
/**
* Stop all sounds.
*/
this.stopAll = () => {
for (let i = 0; i < this.sounds.length; i++) {
this.sounds[i].howl.stop();
}
};
/**
* Stop a specific sound.
*
* @param {string} id - A sound ID.
*/
this.stop = (id) => {
if (this.isPlaying(id)) {
this.findAndCall(id, (sound) => {
sound.howl.stop();
});
}
};
/**
* Pause a specific sound.
*
* @param {string} id - A sound ID.
*/
this.pause = (id) => {
this.findAndCall(id, (sound) => {
if (sound.howl.playing()) {
sound.howl.pause();
this.pauseArray.push(sound.id);
}
});
};
/**
* If given an id it will resume that sound if it is paused
* else it will resume all paused sounds.
*
* @param {string} id - A sound ID.
*/
this.resume = (id) => {
if (id) {
const idIndex = this.pauseArray.indexOf(id);
if (idIndex !== -1) {
this.findAndCall(this.pauseArray[idIndex], (sound) => {
sound.howl.play();
});
this.pauseArray.splice(idIndex, 1);
}
} else {
for (let i = 0; i < this.pauseArray.length; i++) {
this.findAndCall(this.pauseArray[i], (sound) => {
sound.howl.play();
});
}
this.pauseArray = [];
}
};
/**
* Mute all sounds.
*
* @param {boolean} mute - Whether or not to mute.
*/
this.muteAll = (mute) => {
Howler.mute(mute);
};
/**
* Mute all sounds in a list.
*
* @param {string[]} - An array of sound IDs.
* @param {boolean} mute - Whether or not to mute.
*/
this.muteMultiple = (arr, mute) => {
for (let i = 0; i < arr.length; i++) {
this.mute(arr[i], mute);
}
};
/**
* Mute a specific sound.
*
* @param {string} id - A sound ID.
* @param {boolean} mute - Whether or not to mute.
*/
this.mute = (id, mute) => {
this.findAndCall(id, (sound) => {
sound.howl.mute(mute);
});
};
/**
* Load a sound.
*
* @param {Object} options
* @param {string} options.id - The sound ID.
* @param {string} options.src - The URL to the sound.
* @param {boolean} [options.autoplay=false] - Whether to autoplay the sound after load.
* @param {number} [options.volume=1.0] - The sound volume (0.0-1.0).
* @param {boolean} [options.loop=false] - Whether to loop the sound.
* @param {boolean} [options.html5=false] - Whether to force HTML5 audio.
* @param {boolean} [options.preload=true] - Whether to preload the sound.
* @param {function} [options.onLoad] - Callback to run when sound is loaded.
*/
this.load = (options) => {
const sound = new Howl({
src: options.src,
autoplay: options.autoplay ? options.autoplay : false,
volume: options.volume ? options.volume : 1.0,
loop: options.loop ? options.loop : false,
html5: options.html5 ? options.html5 : false,
preload: options.preload ? options.preload : true,
});
if (options.onLoad) {
sound.once('load', options.onLoad);
}
this.sounds.push({
id: options.id,
howl: sound,
});
};
/**
* Load multiple sounds from a list.
*
* @param {Object[]} - An array of objects to pass to {@link module:soundService.load}.
*/
this.loadMultiple = (data) => {
for (let i = 0; i < data.length; i++) {
this.load(data[i]);
}
};
/**
* Unload a sound.
*
* @param {string} id - A sound ID.
*/
this.unload = (id) => {
this.findAndCall(id, (sound) => {
sound.howl.unload();
const i = this.sounds.findIndex((s) => s.id === sound.id);
// eslint-disable-next-line no-param-reassign
sound.id = null;
this.sounds.splice(i, 1);
this.pauseArray.splice(this.pauseArray.indexOf(id), 1);
});
};
/**
* Unload all sounds.
*/
this.unloadAll = () => {
// eslint-disable-next-line no-undef
Howler.unload();
this.sounds = [];
};
/**
* Unload all sounds and clear pause data.
*/
this.reset = () => {
this.unloadAll();
this.pauseArray = [];
};
/**
* Set the volume for a specific sound.
*
* @param {string} id - A sound ID.
* @param {number} volume - The sound volume (0.0-1.0).
*/
this.setVolume = (id, volume) => {
this.findAndCall(id, (sound) => {
sound.howl.volume(volume);
});
};
/**
* Get the volume for a specific sound.
*
* @param {string} id - A sound ID.
* @return {?number} The sound volume (0.0-1.0).
*/
this.getVolume = (id) => this.findAndCall(id, (sound) => sound.howl.volume());
/**
* Set the rate for a specific sound, used for dynamic pitch shifting.
*
* @param {string} id - A sound ID.
* @param {number} rate - The sound rate (0.5-4.0).
*/
this.setRate = (id, rate) => {
this.findAndCall(id, (sound) => {
sound.howl.rate(rate);
});
};
/**
* Check load progress of all sounds.
*
* @return {number} The percentage of sounds that are done loading (0.0-1.0).
*/
this.checkLoadCompletion = () => {
if (this.sounds.length < 1) return -1;
let loaded = 0;
for (let i = 0; i < this.sounds.length; i++) {
if (this.sounds[i].howl.state() === 'loaded') {
loaded++;
}
}
return loaded / this.sounds.length;
};
this.visibilityChange = () => {
// Source 1: https://github.com/goldfire/howler.js/issues/1160
// eslint-disable-next-line max-len
// Source 2 https://stereologics.wordpress.com/2015/04/02/about-page-visibility-api-hidden-visibilitychange-visibilitystate/
let hidden = 'hidden';
const soundServ = this;
function onchange(evt) {
const v = false;
const h = true;
const evtMap = {
focus: v,
focusin: v,
pageshow: v,
blur: h,
focusout: v,
pagehide: h,
};
const changeEvent = evt || $window.event;
let windowHidden = false;
if (changeEvent.type in evtMap) {
windowHidden = evtMap[changeEvent.type];
} else {
windowHidden = this[hidden];
}
if (soundServ) {
if (windowHidden) {
soundServ.muteAll(true);
$log.log('MUTE');
} else {
// If the audio is set to mute by the user already do not bring it back
const isAudio = localDataService.getCookieData('audio');
soundServ.muteAll(false || !isAudio);
$log.log('UN-MUTE', isAudio);
}
}
}
const doc = $document[0];
// Standards:
if (hidden in doc) {
doc.addEventListener('visibilitychange', onchange, true);
} else if ('mozHidden' in doc) {
hidden = 'mozHidden';
doc.addEventListener('mozvisibilitychange', onchange);
} else if ('webkitHidden' in doc) {
hidden = 'webkitHidden';
doc.addEventListener('webkitvisibilitychange', onchange);
} else if ('msHidden' in doc) {
hidden = 'msHidden';
doc.addEventListener('msvisibilitychange', onchange);
} else if ('onfocusin' in doc) {
// IE 9 and lower:
// eslint-disable-next-line no-param-reassign, no-multi-assign
doc.onfocusin = doc.onfocusout = onchange;
} else {
// All others:
// eslint-disable-next-line no-param-reassign, no-multi-assign
$window.onpageshow = $window.onpagehide = $window.onfocus = $window.onblur = onchange;
}
// extra event listeners for better behaviour
doc.addEventListener('focus', () => {
onchange({ type: 'focus' });
}, false);
doc.addEventListener('blur', () => {
onchange({ type: 'blur' });
}, false);
$window.addEventListener('focus', () => {
onchange({ type: 'focus' });
}, false);
$window.addEventListener('blur', () => {
onchange({ type: 'blur' });
}, false);
// set the initial state (but only if browser supports the Page Visibility API)
if (angular.isDefined(doc[hidden])) {
onchange({
type: doc[hidden] ? 'blur' : 'focus',
});
}
};
if (utilityService.isMobile()) {
this.visibilityChange();
}
},
]);