VOPlayer.js

/* global platypus */
import {arrayCache, greenSlice, greenSplice} from './utils/array.js';
import Data from './Data.js';
import Messenger from './Messenger.js';
import Sound from 'pixi-sound';

/**
 * This class is used to create `platypus.game.voPlayer` and manages playback by only playing one at a time, playing a list, and even handling captions at the same time.
 *
 * This class borrows heavily from SpringRoll v1 to match the original capabilities exposed for Platypus v1.
 *
 * @memberof platypus
 * @class VOPlayer
 * @extends platypus.Messenger
 * @param {Game} game The game instance for which to play audio.
 * @param {assetManager} assetCache The Platypus assetManager used to load and unload VO clips.
 */
class VOPlayer extends Messenger {
    constructor (game, assetCache) {
        super();

        this.game = game;
        this.assetCache = assetCache;

        //Bound method calls
        this._onSoundFinished = this._onSoundFinished.bind(this);
        this._updateSilence = this._updateSilence.bind(this);
        this._syncCaptionToSound = this._syncCaptionToSound.bind(this);

        /**
         * Preloading next sound.
         * @property {String} preloadingNextSound
         * @private
         */
        this.preloadingNextSound = '';

        /**
         * If the sound is currently paused. Setting this has no effect - use pause()
         * and resume().
         * @property {Boolean} paused
         * @public
         * @readOnly
         */
        this.paused = false;

        /**
         * The current list of audio/silence times/functions.
         * Generally you will not need to modify this.
         * @property {Array} voList
         * @public
         */
        this.voList = null;

        /**
         * The current position in voList.
         * @property {int} _listCounter
         * @private
         */
        this._listCounter = 0;

        /**
         * The current audio alias being played.
         * @property {String} _currentVO
         * @private
         */
        this._currentVO = null;

        /**
         * The current audio instance being played.
         * @property {SoundInstance} _soundInstance
         * @private
         */
        this._soundInstance = null;

        /**
         * The callback for when the list is finished.
         * @property {Function} _callback
         * @private
         */
        this._callback = null;

        /**
         * The callback for when the list is interrupted for any reason.
         * @property {Function} _cancelledCallback
         * @private
         */
        this._cancelledCallback = null;

        /**
         * A timer for silence entries in the list, in milliseconds.
         * @property {int} _timer
         * @private
         */
        this._timer = 0;

        /**
         * The captions object
         * @property {springroll.Captions} _captions
         * @private
         */
        this._captions = null;

        this.volume = 1;
        this.captionMute = true;
        this.currentlyLoadingAudio = false;
        this.playQueue = arrayCache.setUp();
    }

    /**
     * If VOPlayer is currently playing (audio or silence).
     * @property {Boolean} playing
     * @public
     * @readOnly
     */
    get playing () {
        return this._currentVO !== null || this._timer > 0;
    }

    /**
     * The current VO alias that is playing, even if it is just a caption. If a silence timer
     * is running, currentVO will be null.
     * @property {Boolean} currentVO
     * @public
     * @readOnly
     */
    get currentVO () {
        return this._currentVO;
    }

    /**
     * The springroll.Captions object used for captions. The developer is responsible for initializing this with a captions dictionary config file and a reference to a text field.
     * @property {Captions} captions
     * @public
     */
    set captions (captions) {
        this._captions = captions;
        if (captions) {
            captions.selfUpdate = false;
            this.setCaptionMute(this.captionMute);
        }
    }

    get captions () {
        return this._captions;
    }

    /**
     * The amount of time elapsed in the currently playing item of audio/silence in milliseconds
     * @property {int} currentPosition
     */
    get currentPosition () {
        if (!this.playing) return 0;
        //active audio
        if (this._soundInstance)
            return this._soundInstance.position;
        //captions only
        else if (this._currentVO)
            return this._timer;
        //silence timer
        else
            return this.voList[this._listCounter] - this._timer;
    }

    /**
     * The duration of the currently playing item of audio/silence in milliseconds. If this is waiting on an audio file to load for the first time, it will be 0, as there is no duration data to give.
     * @property {int} currentDuration
     */
    get currentDuration () {
        if (!this.playing) {
            return 0;
        }

        //active audio
        if (this._soundInstance) {
            return Sound.duration(this._soundInstance.alias);
        } else if (this._currentVO && this._captions) { //captions only
            return this._captions.currentDuration;
        } else { //silence timer
            return this.voList[this._listCounter];
        }
    }

    /**
     * Calculates the amount of time elapsed in the current playlist of audio/silence.
     * @method platypus.VOPlayer#getElapsed
     * @return {int} The elapsed time in milliseconds.
     */
    getElapsed () {
        let index = 0,
            total = 0;

        if (!this.voList)
        {
            return 0;
        }

        for (let i = 0; i < this._listCounter; ++i) {
            const item = this.voList[i];
            if (typeof item === "string") {
                total += Sound.duration(item) * 1000;
            }
            else if (typeof item === "number")
            {
                total += item;
            }
        }

        //get the current item
        index = this._listCounter;
        if (index < this.voList.length) {
            const item = this.voList[index];
            if (typeof item === "string") {
                if (this._soundInstance) {
                    total += this._soundInstance._elapsed * 1000;
                } // Otherwise it's not yet loaded so progress is `0`
            } else if (typeof item === "number") {
                total += item - this._timer;
            }
        }
        return total;
    }

    /**
     * Pauses the current VO, caption, or silence timer if the VOPlayer is playing.
     * @method platypus.VOPlayer#pause
     * @public
     */
    pause () {
        if (this.paused || !this.playing) return;

        this.paused = true;

        if (this._soundInstance)
            this._soundInstance.pause();
        
        //remove any update callback
        this.game.off("tick", this._syncCaptionToSound);
        this.game.off("tick", this._updateSilence);
    }

    /**
     * Resumes the current VO, caption, or silence timer if the VOPlayer was paused.
     * @method platypus.VOPlayer#resume
     * @public
     * @listens platypus.Game#tick
     */
    resume () {
        if (!this.paused) return;

        this.paused = false;
        if (this._soundInstance)
            this._soundInstance.resume();
        //captions for solo captions or VO
        if (this._captions.activeCaption) {
            if (this._soundInstance) {
                this.game.on("tick", this._syncCaptionToSound);
            }

            //timer
        } else {
            this.game.on("tick", this._updateSilence);
        }
    }

    /**
     * Plays a single audio alias, interrupting any current playback.
     * Alternatively, plays a list of audio files, timers, and/or functions.
     * Audio in the list will be preloaded to minimize pauses for loading.
     * @method platypus.VOPlayer#play
     * @public
     * @param {String|Array} idOrList The alias of the audio file to play or the
     * array of items to play/call in order.
     * @param {Function} [callback] The function to call when playback is complete.
     * @param {Function|Boolean} [cancelledCallback] The function to call when playback
     * is interrupted with a stop() or play() call. If this value is a boolean
     * <code>true</code> then callback will be used instead.
     */
    play (idOrList, callback, cancelledCallback) {
        if (!this.startingNewTrack) {
            if (this.currentlyLoadingAudio) {
                this.playQueue.push(Data.setUp(
                    'idOrList', idOrList,
                    'callback', callback,
                    'cancelledCallback', cancelledCallback
                ));
                return;
            }
            this.startingNewTrack = true;
            this.stop();
            this.startingNewTrack = false;

            this._listCounter = -1;
            if (typeof idOrList === "string") {
                this.voList = arrayCache.setUp(0, idOrList);
            } else {
                this.voList = greenSlice(idOrList);
                this.voList.unshift(0);
            }
            this._callback = callback;
            this._cancelledCallback = cancelledCallback === true ? callback : cancelledCallback;
            this._onSoundFinished();

        } else {
            platypus.debug.warn('VOPlayer: Tried playing a new track while a new track was starting up.');
        }
    }

    /**
     * Callback for when audio/timer is finished to advance to the next item in the list.
     * @private
     */
    _onSoundFinished () {
        if (this._listCounter >= 0) {
            const currentVO = this._currentVO;

            /**
             * Fired when a new VO, caption, or silence timer completes
             * @event platypus.VOPlayer#end
             * @param {String} currentVO The alias of the VO or caption that has begun, or null if it is a silence timer.
             */
            this.trigger("end", currentVO);
            if (typeof currentVO === "string") {
                this.voList[0] += Sound.duration(currentVO) * 1000;
                this.unloadSound(currentVO);
                greenSplice(this.voList, 1);
                this._listCounter -= 1;
            } else if (typeof this._currentVO === "function") {
                greenSplice(this.voList, 1);
                this._listCounter -= 1;
            } else {
                this.voList[0] += this.voList[1];
                greenSplice(this.voList, 1);
                this._listCounter -= 1;
            }
        } else {
            this._listCounter = 0; // bump past elapsed storage index.
        }
        //remove any update callback
        this.game.off("tick", this._syncCaptionToSound);
        this.game.off("tick", this._updateSilence);

        //if we have captions and an audio instance, set the caption time to the length of the audio
        if (this._captions && this._captions.activeCaption && this._soundInstance) {
            const activeCaption = this._captions.activeCaption;
            
            activeCaption.lineIndex = activeCaption.lines.length;
        }
        this._soundInstance = null; //clear the audio instance
        this._listCounter++; //advance list

        //if the list is complete
        if (this._listCounter >= this.voList.length) {
            const c = this._callback;

            if (this._captions) {
                this._captions.stop();
            }
            this._currentVO = null;
            this._cancelledCallback = null;
            this._callback = null;
            arrayCache.recycle(this.voList);
            this.voList = null;
            if (c) {
                c();
            }
        } else {
            /**
             * Fired when a new VO, caption, or silence timer begins
             * @event platypus.VOPlayer#start
             * @param {String} currentVO The alias of the VO or caption that has begun, or null if it is a silence timer.
             */
            this._currentVO = this.voList[this._listCounter];
            if (typeof this._currentVO === "string") {
                //If the sound doesn't exist, then we play it and let it fail,
                //an error should be shown and playback will continue
                this._playSound();
                this.trigger("start", this._currentVO);
            } else if (typeof this._currentVO === "function") {
                this._currentVO(); //call function
                this._onSoundFinished(); //immediately continue
            } else {
                this._timer = this._currentVO; //set up a timer to wait
                this._currentVO = null;
                this.game.on("tick", this._updateSilence);
                this.trigger("start", null);
            }
        }
    }

    /**
     * The update callback used for silence timers.
     * This method is bound to the VOPlayer instance.
     * @private
     * @param {int} elapsed The time elapsed since the previous frame, in milliseconds.
     */
    _updateSilence (tick) {
        this._timer -= tick.delta;

        if (this._timer <= 0) {
            this._onSoundFinished();
        }
    }

    /**
     * The update callback used for updating captions with active audio.
     * This method is bound to the VOPlayer instance.
     * @private
     * @param {int} elapsed The time elapsed since the previous frame, in milliseconds.
     */
    _syncCaptionToSound (tick) {
        if (!this._soundInstance) return;

        this._captions.update(tick.delta / 1000);
    }

    /**
     * Plays the current audio item and begins preloading the next item.
     * @private
     */
    _playSound () {
        const
            play = () => {
                this._soundInstance = Sound.play(this._currentVO, this._onSoundFinished);
                this._soundInstance.volume = this.volume;
                if (this._captions) {
                    this._captions.start(this._currentVO);
                    this.game.on("tick", this._syncCaptionToSound);
                }
                if (this.playQueue.length) { // We need to skip on ahead, because new VO was played while this or a prior one was loading.
                    const
                        vo = greenSplice(this.playQueue, 0);

                    this.play(vo.idOrList, vo.callback, vo.cancelledCallback);

                    vo.recycle();
                } else {
                    for (let i = this._listCounter + 1; i < this.voList.length; ++i) {
                        const next = this.voList[i];
                        if (typeof next === "string") {
                            const
                                arr = arrayCache.setUp({
                                    id: this._currentVO,
                                    src: this._currentVO + '.mp3'
                                });
        
                            this.assetCache.load(arr);
                
                            arrayCache.recycle(arr);

                            if (this.preloadingNextSound) {
                                this.unloadSound(this.preloadingNextSound);
                            }
                            this.preloadingNextSound = next;
                            break;
                        }
                    }
                }
            },
            arr = arrayCache.setUp({
                id: this._currentVO,
                src: this._currentVO + '.mp3'
            }),
            currentVO = this._currentVO;

        this.currentlyLoadingAudio = true;

        this.assetCache.load(arr, null, () => {
            this.currentlyLoadingAudio = false;
            if (this.stoppedWhileLoading) {
                this.stoppedWhileLoading = false;
                if (this.playQueue.length) { // Already more queued up, so we'll roll the stop into it here
                    play();
                } else {
                    play();
                    this.stop();
                }
            } else if (currentVO === this._currentVO) {
                play();
            } else {
                platypus.debug.warn('VOPlayer: Asset loading out of order.');
            }
        });

        arrayCache.recycle(arr);
    }

    /**
     * Stops playback of any audio/timer.
     * @method platypus.VOPlayer#stop
     * @public
     */
    stop () {
        if (this.currentlyLoadingAudio) {
            this.stoppedWhileLoading = true;
            return;
        }
        const c = this._cancelledCallback;

        this.paused = false;
        if (this._soundInstance) {
            this._soundInstance.stop();
            this.unloadSound(this._currentVO);
            this._soundInstance = null;
        }
        this._currentVO = null;
        if (this._captions && this._captions.activeCaption) {
            this._captions.stop();
        }
        this.game.off("tick", this._syncCaptionToSound);
        this.game.off("tick", this._updateSilence);
        if (this.voList) {
            for (let i = this._listCounter + 1; i < this.voList.length; i++) {
                if (typeof this.voList[i] === 'function') {
                    this.voList[i](); // Make sure all events are triggered.
                }
            }
            this.voList = null;
        }
        this._timer = 0;
        this._callback = null;

        this._cancelledCallback = null;
        if (c) {
            c();
        }
    }

    /**
     * Sets the volume of VO playback.
     *
     * @method platypus.VOPlayer#setVolume
     * @param {Number} volume
     */
    setVolume (volume) {
        this.volume = volume;
        if (this._soundInstance) {
            this._soundInstance.volume = this.volume;
        }
    }

    /**
     * Whether to mute captions.
     *
     * @method platypus.VOPlayer#setCaptionMute
     * @param {Boolean} muted
     */
    setCaptionMute (muted) {
        this.captionMute = muted;
        if (this._captions) {
            this._captions.renderer.renderTarget.style.display = muted ? 'none' : 'block';
        }
    }

    /**
     * Unloads an audio track this VOPlayer has played.
     * @method platypus.VOPlayer#unloadSound
     * @param sound {string} Sound to unload.
     * @public
     */
    unloadSound (sound) {
        const
            assetCache = this.assetCache;

        if (assetCache.delete(sound)) {
            Sound.remove(sound);
        }
    }

    /**
     * Cleans up this VOPlayer.
     * @method platypus.VOPlayer#destroy
     * @public
     */
    destroy () {
        this.stop();
        this.voList = null;
        this._currentVO = null;
        this._soundInstance = null;
        this._callback = null;
        this._cancelledCallback = null;
        this._captions = null;
    }
};

export default VOPlayer;