nbsrc/provider/HowlerSoundProvider.js

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};