File:VOPlayer.js

/**
 * @module Sound
 * @namespace springroll
 * @requires Core
 */
(function()
{
	//Class Imports, we'll actually include them in the constructor
	//in case these classes were included after in the load-order
	var Sound = include('springroll.Sound'),
		Captions,
		Application = include('springroll.Application'),
		EventDispatcher = include('springroll.EventDispatcher');

	/**
	 * A class for managing audio by only playing one at a time, playing a list,
	 * and even managing captions (Captions library) at the same time.
	 * @class VOPlayer
	 */
	var VOPlayer = function()
	{
		EventDispatcher.call(this);

		//Import classes
		if (!Captions)
		{
			Captions = include('springroll.Captions', false);
		}

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

		/**
		 * An Array used when play() is called to avoid creating lots of Array objects.
		 * @property {Array} _listHelper
		 * @private
		 */
		this._listHelper = [];

		/**
		 * If the VOPlayer should keep a list of all audio it plays for unloading
		 * later. Default is false.
		 * @property {Boolean} trackSound
		 * @public
		 */
		this.trackSound = false;

		/**
		 * 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 list of audio file played by this, so that they can be unloaded later.
		 * @property {Array} _trackedSounds
		 * @private
		 */
		this._trackedSounds = [];

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

	var p = extend(VOPlayer, EventDispatcher);

	/**
	 * Fired when a new VO, caption, or silence timer begins
	 * @event start
	 * @param {String} currentVO The alias of the VO or caption that has begun, or null if it is
	 *                           a silence timer.
	 */

	/**
	 * Fired when a new VO, caption, or silence timer completes
	 * @event end
	 * @param {String} currentVO The alias of the VO or caption that has begun, or null if it is
	 *                           a silence timer.
	 */

	/**
	 * If VOPlayer is currently playing (audio or silence).
	 * @property {Boolean} playing
	 * @public
	 * @readOnly
	 */
	Object.defineProperty(p, "playing",
	{
		get: function()
		{
			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
	 */
	Object.defineProperty(p, "currentVO",
	{
		get: function()
		{
			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
	 */
	Object.defineProperty(p, "captions",
	{
		set: function(captions)
		{
			this._captions = captions;
			if (captions)
			{
				captions.selfUpdate = false;
			}
		},
		get: function()
		{
			return this._captions;
		}
	});

	/**
	 * The amount of time elapsed in the currently playing item of audio/silence in milliseconds
	 * @property {int} currentPosition
	 */
	Object.defineProperty(p, "currentPosition",
	{
		get: function()
		{
			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
	 */
	Object.defineProperty(p, "currentDuration",
	{
		get: function()
		{
			if (!this.playing) return 0;
			//active audio
			if (this._soundInstance)
				return Sound.instance.getDuration(this._soundInstance.alias);
			//captions only
			else if (this._currentVO && this._captions)
				return this._captions.currentDuration;
			//silence timer
			else
				return this.voList[this._listCounter];
		}
	});

	/**
	 * Calculates the amount of time elapsed in the current playlist of audio/silence.
	 * @method getElapsed
	 * @return {int} The elapsed time in milliseconds.
	 */
	p.getElapsed = function()
	{
		var total = 0,
			item, i;

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

		for (i = 0; i < this._listCounter; ++i)
		{
			item = this.voList[i];
			if (typeof item == "string")
			{
				total += Sound.instance.getDuration(item);
			}
			else if (typeof item == "number")
			{
				total += item;
			}
		}
		//get the current item
		i = this._listCounter;
		if (i < this.voList.length)
		{
			item = this.voList[i];
			if (typeof item == "string")
			{
				total += this._soundInstance.position;
			}
			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 pause
	 * @public
	 */
	p.pause = function()
	{
		if (this.paused || !this.playing) return;

		this.paused = true;

		if (this._soundInstance)
			this._soundInstance.pause();
		//remove any update callback
		Application.instance.off("update", [
			this._updateSoloCaption,
			this._syncCaptionToSound,
			this._updateSilence
		]);
	};

	/**
	 * Resumes the current VO, caption, or silence timer if the VOPlayer was paused.
	 * @method resume
	 * @public
	 */
	p.resume = function()
	{
		if (!this.paused) return;

		this.paused = false;
		if (this._soundInstance)
			this._soundInstance.resume();
		//captions for solo captions or VO
		if (this._captions.playing)
		{
			if (this._soundInstance)
				Application.instance.on("update", this._syncCaptionToSound);
			else
				Application.instance.on("update", this._updateSoloCaption);
		}
		//timer
		else
		{
			Application.instance.on("update", 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 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.
	 */
	p.play = function(idOrList, callback, cancelledCallback)
	{
		this.stop();

		//Handle the case where a cancel callback starts
		//A new VO play. Inline VO call should take priority
		//over the cancelled callback VO play.
		if (this.playing)
		{
			this.stop();
		}

		this._listCounter = -1;
		if (typeof idOrList == "string")
		{
			this._listHelper.length = 0;
			this._listHelper[0] = idOrList;
			this.voList = this._listHelper;
		}
		else
		{
			this.voList = idOrList;
		}
		this._callback = callback;
		this._cancelledCallback = cancelledCallback === true ? callback : cancelledCallback;
		this._onSoundFinished();
	};

	/**
	 * Callback for when audio/timer is finished to advance to the next item in the list.
	 * @method _onSoundFinished
	 * @private
	 */
	p._onSoundFinished = function()
	{
		if (this._listCounter >= 0)
			this.trigger("end", this._currentVO);
		//remove any update callback
		Application.instance.off("update", [
			this._updateSoloCaption,
			this._syncCaptionToSound,
			this._updateSilence
		]);

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

		//if the list is complete
		if (this._listCounter >= this.voList.length)
		{
			if (this._captions)
			{
				this._captions.stop();
			}
			this._currentVO = null;
			this._cancelledCallback = null;

			var c = this._callback;
			this._callback = null;
			if (c)
			{
				c();
			}
		}
		else
		{
			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;
				Application.instance.on("update", this._updateSilence);
				this.trigger("start", null);
			}
		}
	};

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

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

	/**
	 * The update callback used for updating captions without active audio.
	 * This method is bound to the VOPlayer instance.
	 * @method _updateSoloCaption
	 * @private
	 * @param {int} elapsed The time elapsed since the previous frame, in milliseconds.
	 */
	p._updateSoloCaption = function(elapsed)
	{
		this._timer += elapsed;
		this._captions.seek(this._timer);

		if (this._timer >= this._captions.currentDuration)
		{
			this._onSoundFinished();
		}
	};

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

		this._captions.seek(this._soundInstance.position);
	};

	/**
	 * Plays the current audio item and begins preloading the next item.
	 * @method _playSound
	 * @private
	 */
	p._playSound = function()
	{
		// Only add a sound once
		if (this.trackSound && this._trackedSounds.indexOf(this._currentVO) == -1)
		{
			this._trackedSounds.push(this._currentVO);
		}
		var s = Sound.instance;
		if (!s.exists(this._currentVO) &&
			this._captions &&
			this._captions.hasCaption(this._currentVO))
		{
			this._captions.play(this._currentVO);
			this._timer = 0;
			Application.instance.on("update", this._updateSoloCaption);
		}
		else
		{
			this._soundInstance = s.play(this._currentVO, this._onSoundFinished);
			if (this._captions)
			{
				this._captions.play(this._currentVO);
				Application.instance.on("update", this._syncCaptionToSound);
			}
		}
		var len = this.voList.length;
		var next;
		for (var i = this._listCounter + 1; i < len; ++i)
		{
			next = this.voList[i];
			if (typeof next == "string")
			{
				if (s.exists(next) && !s.isLoaded(next))
				{
					s.preload(next);
				}
				break;
			}
		}
	};

	/**
	 * Stops playback of any audio/timer.
	 * @method stop
	 * @public
	 */
	p.stop = function()
	{
		this.paused = false;
		if (this._soundInstance)
		{
			this._soundInstance.stop();
			this._soundInstance = null;
		}
		this._currentVO = null;
		if (this._captions)
		{
			this._captions.stop();
		}
		Application.instance.off('update', [
			this._updateSoloCaption,
			this._syncCaptionToSound,
			this._updateSilence
		]);
		this.voList = null;
		this._timer = 0;
		this._callback = null;

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

	/**
	 * Unloads all audio this VOPlayer has played. If trackSound is false, this won't do anything.
	 * @method unloadSound
	 * @public
	 */
	p.unloadSound = function()
	{
		Sound.instance.unload(this._trackedSounds);
		this._trackedSounds.length = 0;
	};

	/**
	 * Cleans up this VOPlayer.
	 * @method destroy
	 * @public
	 */
	p.destroy = function()
	{
		this.stop();
		this.voList = null;
		this._listHelper = null;
		this._currentVO = null;
		this._soundInstance = null;
		this._callback = null;
		this._cancelledCallback = null;
		this._trackedSounds = null;
		this._captions = null;
		EventDispatcher.prototype.destroy.call(this);
	};

	namespace('springroll').VOPlayer = VOPlayer;
	namespace('springroll').Sound.VOPlayer = VOPlayer;

}());