goog.module('nbsrc.provider.HowlerSoundProvider');
const Completer = goog.require('clulib.async.Completer');
const Event = goog.require('goog.events.Event');
const EventTarget = goog.require('goog.events.EventTarget');
const XhrIo = goog.require('goog.net.XhrIo');
const array = goog.require('goog.array');
const {getFileName} = goog.require('nbsrc.utils.parser');
/**
* This provider (singleton) takes care of loading and controlling external sound via {@Link https://howlerjs.com/}.
* @extends {EventTarget}
*/
class HowlerSoundProvider extends EventTarget
{
constructor()
{
super();
/**
* Defines the default relative sound asset path.
* @type {string}
* @protected
*/
this.soundAssetPath_ = 'asset/sounds/';
/**
* It is an absolute asset URL if the variable `window.ASSET_URL` is set, otherwise it is the relative {@Link HowlerSoundProvider#soundAssetPath_}.
* @type {string}
* @protected
*/
this.soundAssetUrl_ = window['ASSETS_URL'] ? window['ASSETS_URL'] + this.soundAssetPath_ : this.soundAssetPath_;
/**
* All loaded sounds will be saved in this list. The key is the sound file name (without the extension).
* @type {Map<string,HowlSound>}
* @protected
*/
this.sounds_ = new Map();
/**
* List of stored loading promises, where key ist the file name (without the extension).
* @type {Map<string,Promise>}
* @protected
*/
this.soundLoadPromise_ = new Map();
/**
* Defines if all handled sounds should be muted.
* @type {boolean}
* @protected
*/
this.muted_ = false;
/**
* Defines a list of sound keys (file names without the extension) which should be excluded from muting.
* @type {Array<string>}
* @protected
*/
this.muteExeptions_ = [];
}
/**
* Adds a sound key or a list of sound keys (file names without the extension) to the exclude list for muting.
* @param {string|Array<string>} keyOrArrayOfKeys Key is a file name without the extension
*/
addMuteExeptions(keyOrArrayOfKeys)
{
if(typeof keyOrArrayOfKeys == 'string')
{
if (this.muteExeptions_.indexOf(keyOrArrayOfKeys) == -1)
this.muteExeptions_.push(keyOrArrayOfKeys);
}
else if(keyOrArrayOfKeys && keyOrArrayOfKeys.length && keyOrArrayOfKeys.length> 1)
{
keyOrArrayOfKeys.forEach((key) => {
this.addMuteExeptions(key);
});
}
}
/**
* Loads a sound or a list of sounds and store them in the {@Link HowlerSoundProvider#sound_} list.
* It is also possible to use a manifest.json to load the sounds.
* Returns a promise that is fulfilled when the source is loaded and saved in the {@Link HowlerSoundProvider#soundLoadPromise_} list.
* @param {Array<string>|string} sounds File path(s).
* @param {boolean=} preventCDNService Prevent loading over a cdn url, which is part of {@Link HowlerSoundProvider#soundAssetUrl_}. Loading path will be relative stating with {@Link HowlerSoundProvider#soundAssetPath_}.
* @return {Promise}
*/
load(sounds, preventCDNService = false)
{
let promiseFiles = [];
let promiseSounds = [];
let urls = [];
let sound = null;
let filename = '';
let manifest = null;
let sourcePath = preventCDNService ? this.soundAssetPath : this.soundAssetUrl;
if(typeof sounds == 'string')
{
urls.push(sourcePath + sounds);
}
else
{
sounds.forEach((sound) => {
urls.push(sourcePath + sound);
});
}
for (let i = 0; i < urls.length; i++)
{
if (typeof urls[i] == "string")
{
if(this.soundLoadPromise_.has(urls[i]))
{
promiseSounds.push(this.soundLoadPromise_.get(urls[i]));
}
else
{
let muted = this.muted_;
if (this.isJsonFile_( /** @type {string} */ (urls[i])))
{
let completer = new Completer();
XhrIo.send(urls[i], (event) => {
let result = event.currentTarget.getResponseJson();
if(result['urls'] && result['src'] == undefined)
result['src'] = result['urls'];
muted = this.muted_ && this.muteExeptions_.indexOf(urls[i]) == -1;
sound = new HowlSound(result, null, muted);
this.sounds_.set(urls[i], sound);
let promise = sound.load();
this.soundLoadPromise_.set(urls[i], promise);
promiseSounds.push(promise);
completer.resolve(result);
}, 'GET');
promiseFiles.push(completer.getPromise());
}
else
{
manifest = {
'src': [urls[i]]
};
filename = getFileName(urls[i]);
muted = this.muted_ && this.muteExeptions_.indexOf(urls[i]) == -1;
sound = new HowlSound(manifest, [filename], muted);
this.sounds_.set(urls[i], sound);
let promise = sound.load();
this.soundLoadPromise_.set(urls[i], promise);
promiseSounds.push(promise);
}
}
}
}
let completer = new Completer();
Promise.all(promiseFiles).then(
() => {
Promise.all(promiseSounds).then(
() => {
completer.resolve();
},
(error) => {
completer.reject(error);
}
);
},
(error) => {return error;}
);
return completer.getPromise();
}
/**
* Check if the specified file path represents a json file.
* @param {string} path
* @return {boolean}
* @protected
*/
isJsonFile_(path)
{
return path.substring(path.lastIndexOf(".") + 1).toLowerCase() == "json";
}
/**
* Change the volume of the sound with the given key (file name without the extension)
* @param {string} key Sound file name without the extension
* @param {number} volume
*/
changeVolume(key, volume)
{
let sound = this.getSoundByKey(key);
if (sound)
sound.changeVolume(volume);
}
/**
* Fade the volume of the sound with the given key (file name without the extension)
* Returns a promise that is fulfilled when the sound fade was completed.
* @param {string} key Sound file name without the extension
* @param {number} from Starting volume
* @param {number} to Starting volume
* @param {number} duration in seconds
* @param {boolean=} loop after fading
* @param {boolean=} preventStop
* @return {Promise}
*/
fadeSound(key, from, to, duration, loop = false, preventStop = false)
{
let sound = this.getSoundByKey(key);
if (sound)
{
let soundPromise = sound.fade(key, from, to, duration * 1000, loop, preventStop);
if(this.muteExeptions_.indexOf(key) != -1)
sound.muted = false;
else
sound.muted = this.muted_;
return soundPromise;
}
return Promise.resolve();
}
/**
* Play a sound with the given key (file name without the extension)
* Returns a promise that is fulfilled when the sound was played.
* @param {string} key Sound file name without the extension
* @param {number=} volume
* @param {boolean=} loop
* @return {Promise}
*/
playSound(key, volume = 1, loop = false)
{
let sound = this.getSoundByKey(key);
if(sound)
{
let soundPromise = sound.play(key, Math.round((volume) * 1000) / 1000, loop);
if(this.muteExeptions_.indexOf(key) != -1)
sound.muted = false;
else
sound.muted = this.muted_;
return soundPromise;
}
return Promise.resolve();
}
/**
* Checks if a sound with the given key (file name without the extension) is playing.
* @param {string} key Sound file name without the extension
* @return {boolean}
*/
isSoundPlaying(key)
{
let sound = this.getSoundByKey(key);
if(sound)
return sound.isPlaying(key);
return false;
}
/**
* Pause or unpause a sound with the given key (file name without the extension).
* @param {string} key Sound file name without the extension
* @param {boolean} enable
* @return {Promise}
*/
pauseSound(key, enable)
{
let sound = this.getSoundByKey(key);
if (sound)
sound.pause(enable);
return Promise.resolve();
}
/**
* Stop a sound with the given key (file name without the extension).
* If the sound was playing its play promise will be rejected.
* @param {string} key Sound file name without the extension
* @return {Promise}
*/
stopSound(key)
{
let sound = this.getSoundByKey(key);
if (sound)
sound.stop();
return Promise.resolve();
}
/**
* Stop all sounds
*/
stopAllSounds()
{
this.sounds_.forEach(
(sound) => {
sound.stop();
}
);
}
/**
* Mute or unmute a sound with the given key (file name without the extension).
* @param {string} key Sound file name without the extension
* @param {boolean} value
*/
muteSoundByKey(key, value)
{
let sound = this.getSoundByKey(key);
if (sound && this.muteExeptions_.indexOf(key) == -1)
sound.muted = value;
}
/**
* Gets the duration of a sound with the given key (file name without the extension).
* @param {string} key Sound file name without the extension
* @return {number}
*/
getSoundDuration(key)
{
let sound = this.getSoundByKey(key);
if (sound)
return sound.duration;
return 0;
}
/**
* Gets the sound instance of a sound with the given key (file name without the extension).
* @param {string} key Sound file name without the extension
* @return {HowlSound}
*/
getSoundByKey(key)
{
let searchedSound = null;
this.sounds_.forEach((sound, id) => {
if (sound.hasKey(key) || id == key)
searchedSound = sound;
});
return searchedSound;
}
/**
* Gets a list of sound keys (filenames without extension) where the sound is currently playing.
* @return {Array<string>}
*/
getActiveInstanceKeys()
{
let activeInstances = new Array();
this.sounds_.forEach(sound => {
let key = sound.getActiveSoundKey();
if (key != null && this.isSoundPlaying(key))
activeInstances.push(key);
});
return activeInstances;
}
/**
* Setter
* @param {boolean} value
*/
set muted(value)
{
if (this.muted_ != value)
{
this.muted_ = value;
this.sounds_.forEach((sound, key) => {
if(this.muteExeptions_.indexOf(key) == -1)
sound.muted = value;
});
this.dispatchEvent(new Event(value ? HowlerSoundEventType.MUTE : HowlerSoundEventType.UNMUTE))
}
}
/**
* Getter
* @return {boolean}
*/
get muted()
{
return this.muted_;
}
/**
* Setter
* @param {string} soundAssetPath
*/
set soundAssetPath(soundAssetPath)
{
this.soundAssetPath_ = soundAssetPath;
this.soundAssetUrl_ = window['ASSETS_URL'] ? window['ASSETS_URL']+soundAssetPath : soundAssetPath;
}
/**
* Getter
* @return {string}
*/
get soundAssetPath()
{
return this.soundAssetPath_;
}
/**
* Getter
* @return {string}
*/
get soundAssetUrl()
{
return this.soundAssetUrl_;
}
}
/**
* @enum {string}
*/
const HowlerSoundEventType = {
/** mute */ MUTE: 'mute',
/** unmute */ UNMUTE: 'unmute'
};
goog.addSingletonGetter(HowlerSoundProvider);
/**
* This class represents a sound object and controls its playback.
*/
class HowlSound {
/**
* @param {Object} manifest
* @param {Array<string>=} keys
* @param {boolean=} muted
*/
constructor(manifest, keys = null, muted = false)
{
/**
* @type {Function}
* @protected
*/
this.soundCompleteFunction_ = this.handleSoundComplete_.bind(this);
/**
* @type {Object}
* @protected
*/
this.manifest_ = manifest;
/**
* @type {boolean}
* @protected
*/
this.isAudioSprite_ = false;
/**
* @type {Array<string>}
* @protected
*/
this.soundKeys_ = keys;
if(!this.soundKeys_)
{
this.isAudioSprite_ = true;
this.soundKeys_ = [];
for (let key in manifest['sprite'])
{
this.soundKeys_.push(key);
}
}
/**
* Howl instance {@Link https://howlerjs.com/}
* @type {Howl}
* @protected
*/
this.sound_ = null;
/**
* Completer is a helper which returns a promise. Its solved if the loading of the sound is completed.
* @type {Completer}
* @protected
*/
this.completer_ = null;
/**
* @type {string}
* @protected
*/
this.activeSoundKey_ = '';
/**
* @type {number|null}
* @protected
*/
this.activeInstanceId_ = null;
/**
* @type {boolean}
* @protected
*/
this.muted_ = muted || false;
/**
* @type {boolean}
* @protected
*/
this.paused_ = false;
/**
* @type {boolean}
* @protected
*/
this.isLooping_ = false;
}
/**
* Load the sound file
* @return {Promise}
*/
load()
{
let completer = new Completer();
this.manifest_['onload'] = () => {
if(!completer.hasCompleted())
completer.resolve();
};
this.manifest_['onloaderror'] = () => {
console.warn("Sound failed to load!");
if(!completer.hasCompleted())
completer.reject("Sound failed to load!");
};
this.sound_ = new Howl(this.manifest_);
return completer.getPromise();
};
/**
* @param {string} key
* @param {number=} volume
* @param {boolean=} loop
* @return {Promise}
*/
play(key, volume = 1, loop = false)
{
if(this.activeInstanceId_)
this.stop();
this.activeSoundKey_ = key;
this.completer_ = new Completer();
this.activeInstanceId_ = this.sound_.play(this.isAudioSprite_ ? key : undefined);
this.isLooping_ = loop;
this.paused_ = false;
if(loop)
{
this.sound_.loop(loop, this.activeInstanceId_);
}
else
{
this.sound_.on('end', this.soundCompleteFunction_);
}
this.sound_.volume(volume, this.activeInstanceId_);
this.changeMute_(this.muted_);
return this.completer_.getPromise();
}
/**
* @param {number} volume
*/
changeVolume(volume)
{
if(this.activeInstanceId_)
this.sound_.volume(volume, this.activeInstanceId_);
}
/**
* @param {string} key
* @param {number} from
* @param {number} to
* @param {number} duration in milliseconds
* @param {boolean=} loop
* @param {boolean=} preventStop
* @return {Promise}
*/
fade(key, from, to, duration, loop = false, preventStop = false)
{
if(this.activeInstanceId_ == null || !this.sound_.playing(this.activeInstanceId_) || this.activeSoundKey_ != key)
{
this.play(key, to, loop);
}
else if(this.activeInstanceId_ != null && this.sound_.playing(this.activeInstanceId_) && to == 0)
{
setTimeout(() => {
if (this.completer_ && !this.completer_.hasCompleted())
this.completer_.resolve();
if(!preventStop)
this.stop();
}, duration);
}
setTimeout(() => {
this.changeMute_(this.muted_);
this.sound_.fade(from, to, duration, this.activeInstanceId_);
}, 0);
return this.completer_.getPromise();
}
/**
* @param {boolean} enable
*/
pause(enable)
{
if(this.activeInstanceId_)
{
if(enable)
this.sound_.pause(this.activeInstanceId_);
else
this.sound_.play(this.activeInstanceId_);
this.paused_ = enable;
}
}
stop()
{
if(this.completer_ && !this.completer_.hasCompleted())
{
this.completer_.reject("Sound was stopped before it could complete!");
this.completer_ = null;
}
if(this.activeInstanceId_)
{
this.sound_.off('end', this.soundCompleteFunction_);
this.sound_.stop(this.activeInstanceId_);
}
this.paused_ = false;
this.isLooping_ = false;
this.activeSoundKey_ = '';
this.activeInstanceId_ = null;
}
/**
* @protected
*/
handleSoundComplete_()
{
if(this.completer_ && !this.completer_.hasCompleted())
{
this.completer_.resolve();
this.completer_ = null;
}
this.stop();
}
/**
* @param {string} key
* @return {boolean}
*/
hasKey(key)
{
if(this.soundKeys_)
return array.contains(this.soundKeys_, key);
return false;
}
/**
* @return {string|null}
*/
getActiveSoundKey()
{
if (this.activeSoundKey_ != '')
return this.activeSoundKey_;
return null;
}
/**
* @return {number|null}
*/
getActiveInstance()
{
return this.activeInstanceId_;
}
/**
* @protected
* @param {boolean} value
*/
changeMute_(value)
{
if(value)
{
this.sound_.mute(true, this.activeInstanceId_);
}
else
{
this.sound_.mute(false, this.activeInstanceId_);
}
}
/**
* @param {string} key
* @return {boolean}
*/
isPlaying(key)
{
return this.activeSoundKey_ == key && this.activeInstanceId_ != null && this.sound_.playing(this.activeInstanceId_);
}
/**
* Getter
* @return {Howl}
*/
get howlInstance()
{
return this.sound_;
}
/**
* Setter
* @param {boolean} value
*/
set muted(value)
{
this.muted_ = value;
this.changeMute_(value);
}
/**
* Getter
* @return {boolean}
*/
get muted()
{
return this.muted_;
}
/**
* Getter
* @return {boolean}
*/
get paused()
{
return this.paused_;
}
/**
* Getter
* @return {number}
*/
get duration()
{
if(this.sound_)
{
return this.sound_.duration();
}
return 0;
}
/**
* Setter
* @param {number} time
*/
set seek(time)
{
if (this.sound_)
{
this.sound_.seek(time);
}
}
/**
* Getter
* @return {number}
*/
get seek()
{
if(this.sound_)
{
let progress = /** @type {number} */ (this.sound_.seek());
return progress;
}
return 0;
}
/**
* Setter
* @param {number} rate
*/
set rate(rate)
{
if (this.sound_)
{
this.sound_.rate(rate);
}
}
/**
* Getter
* @return {number}
*/
get rate()
{
if(this.sound_)
{
let rate = /** @type {number} */ (this.sound_.rate());
return rate;
}
return 0;
}
}
exports = {HowlerSoundProvider,HowlerSoundEventType,HowlSound};