File:Captions.js

/**
 * @module Captions
 * @namespace springroll
 * @requires Core
 */
(function(undefined)
{
	//Import class
	var Application = include('springroll.Application'),
		Debug;

	/**
	 * A class that creates captioning for multimedia content. Captions are
	 * created from a dictionary of captions and can be played by alias.
	 * @example
		var captionsData = {
			"Alias1": [
				{"start":0, "end":2000, "content":"Ohh that looks awesome!"}
			],
			"Alias2": [
				{"start":0, "end":2000, "content":"Love it, absolutely love it!"}
			]
		};

		//initialize the captions
		var captions = new springroll.Captions();
		captions.data = captionsData;
		captions.textField = document.getElementById("captions");
		captions.play("Alias1");
	 * @class Captions
	 * @constructor
	 * @param {Object} [data=null] The captions dictionary
	 * @param {String|DOMElement} [textField=null] The output text field
	 * @param {Boolean} [selfUpdate=true] If the captions playback should update itself
	 */
	var Captions = function(data, textField, selfUpdate)
	{
		Debug = include('springroll.Debug', false);

		/**
		 * An object used as a dictionary with keys that should be the same as sound aliases
		 * @private
		 * @property {Object} _data
		 */
		this._data = null;

		/**
		 * A reference to the Text object that Captions should be controlling.
		 * Only one text field can be controlled at a time.
		 * @private
		 * @property {createjs.Text|PIXI.Text|PIXI.BitmapText|DOMElement} _textField
		 */
		this._textField = null;

		/**
		 * The function to call when playback is complete.
		 * @private
		 * @property {Function} _completeCallback
		 */
		this._completeCallback = null;

		/**
		 * The collection of line objects - {start:0, end:0, content:""}
		 * @private
		 * @property {Array} _lines
		 */
		this._lines = [];

		/**
		 * The alias of the current caption.
		 * @private
		 * @property {String} _currentAlias
		 */
		this._currentAlias = 0;

		/**
		 * The duration in milliseconds of the current caption.
		 * @private
		 * @property {int} _currentDuration
		 */
		this._currentDuration = 0;

		/**
		 * The current playback time, in milliseconds.
		 * @private
		 * @property {int} _currentTime
		 */
		this._currentTime = 0;

		/**
		 * The current line index.
		 * @private
		 * @property {int} _currentLine
		 */
		this._currentLine = -1;

		/**
		 * The last active line index.
		 * @private
		 * @property {int} _lastActiveLine
		 */
		this._lastActiveLine = -1;

		/**
		 * If we're playing.
		 * @private
		 * @property {Boolean} _playing
		 */
		this._playing = false;

		/**
		 * If this instance has been destroyed already.
		 * @private
		 * @property {Boolean} _destroyed
		 */
		this._destroyed = false;

		/**
		 * If the captions object should do its own update.
		 * @property {Boolean} _selfUpdate
		 * @private
		 * @default true
		 */
		this._selfUpdate = true;

		/**
		 * If the captions are muted
		 * @property {Boolean} _mute
		 * @private
		 * @default false
		 */
		this._mute = false;

		//Bind the update function
		this.update = this.update.bind(this);

		//Set with preset
		this.data = data ||
		{};
		this.textField = textField || null;
		this.selfUpdate = selfUpdate === undefined ? true : !!selfUpdate;
	};

	/**
	 * Reference to the prototype
	 * @static
	 * @private
	 * @property {Object} p
	 */
	var p = extend(Captions);

	/**
	 * Set if all captions are currently muted.
	 * @property {Boolean} mute
	 * @default false
	 */
	Object.defineProperty(p, 'mute',
	{
		get: function()
		{
			return this._mute;
		},
		set: function(mute)
		{
			this._mute = mute;
			this._updateCaptions();
		}
	});

	/**
	 * If the captions object should do it's own updating unless you want to manuall
	 * seek. In general, self-updating should not be set to false unless the sync
	 * of the captions needs to be exact with something else.
	 * @property {Boolean} selfUpdate
	 * @default true
	 */
	Object.defineProperty(p, 'selfUpdate',
	{
		set: function(selfUpdate)
		{
			this._selfUpdate = !!selfUpdate;
			Application.instance.off('update', this.update);

			if (this._selfUpdate)
			{
				Application.instance.on('update', this.update);
			}
		},
		get: function()
		{
			return this._selfUpdate;
		}
	});

	/**
	 * Sets the dictionary object to use for captions. This overrides the current
	 * dictionary, if present.
	 * @property {Object} data
	 */
	Object.defineProperty(p, 'data',
	{
		set: function(dict)
		{
			this._data = dict;

			if (!dict) return;

			var timeFormat = /[0-9]+\:[0-9]{2}\:[0-9]{2}\.[0-9]{3}/;

			//Loop through each line and make sure the times are formatted correctly
			var lines, i, l, len;
			for (var alias in dict)
			{
				//account for a compressed format that is just an array of lines
				//and convert it to an object with a lines property.
				if (Array.isArray(dict[alias]))
				{
					dict[alias] = {
						lines: dict[alias]
					};
				}
				lines = dict[alias].lines;
				if (!lines)
				{
					if (DEBUG && Debug)
					{
						Debug.log("alias '" + alias + "' has no lines!");
					}
					continue;
				}
				len = lines.length;
				for (i = 0; i < len; ++i)
				{
					l = lines[i];
					if (typeof l.start == "string")
					{
						if (timeFormat.test(l.start))
						{
							l.start = _timeCodeToMilliseconds(l.start);
						}
						else
						{
							l.start = parseInt(l.start, 10);
						}
					}
					if (typeof l.end == "string")
					{
						if (timeFormat.test(l.end))
						{
							l.end = _timeCodeToMilliseconds(l.end);
						}
						else
						{
							l.end = parseInt(l.end, 10);
						}
					}
				}
			}
		},
		get: function()
		{
			return this._data;
		}
	});

	/**
	 * The text field that the captions uses to update.
	 * @property {String|createjs.Text|PIXI.Text|PIXI.BitmapText|DOMElement} textField
	 */
	Object.defineProperty(p, 'textField',
	{
		set: function(field)
		{
			setText(this._textField, '');
			this._textField = (typeof field === "string" ?
				document.getElementById(field) :
				(field || null));
		},
		get: function()
		{
			return this._textField;
		}
	});

	/**
	 * Automatically determine how to set the text field text
	 * @method setText
	 * @private
	 * @static
	 * @param {createjs.Text|PIXI.Text|PIXI.BitmapText|DOMElement} field The text field to change
	 * @param {String} text The text to set it to
	 * @return {createjs.Text|PIXI.Text|PIXI.BitmapText|DOMElement} The text field
	 */
	var setText = function(field, text)
	{
		if (!field) return;

		//DOM element
		if (field.nodeName)
		{
			field.innerHTML = text;
		}
		//the EaselJS/PIXI v3 style text setting
		else if (field.constructor.prototype.hasOwnProperty("text") ||
			field.hasOwnProperty("text"))
		{
			field.text = text;
		}
		//unsupported field type, oops!
		else
		{
			throw "Unrecognizable captions text field";
		}
		return field;
	};

	/**
	 * Returns if there is a caption under that alias or not.
	 * @method  hasCaption
	 * @param {String} alias The alias to check against
	 * @return {Boolean} Whether the caption was found or not
	 */
	p.hasCaption = function(alias)
	{
		return this._data ? !!this._data[alias] : false;
	};

	/**
	 * A utility function for getting the full text of a caption by alias
	 * this can be useful for debugging or tracking purposes.
	 * @method  getFullCaption
	 * @param {String|Array} alias The alias or Array of aliases for which to get the text.
	 *                           Any non-String values in this Array are silently and
	 *                           harmlessly ignored.
	 * @param {String} [separator=" "] The separation between each line.
	 * @return {String} The entire caption, concatinated by the separator.
	 */
	p.getFullCaption = function(alias, separator)
	{
		if (!this._data) return;

		separator = separator || " ";

		var result,
			content,
			i;

		if (Array.isArray(alias))
		{
			for (i = 0; i < alias.length; i++)
			{
				if (typeof alias[i] == 'string')
				{
					content = this.getFullCaption(alias[i], separator);
					if (!result)
					{
						result = content;
					}
					else
					{
						result += separator + content;
					}
				}
			}
		}
		else
		{
			//return name if no caption so as not to break lists of mixed SFX and VO
			if (!this._data[alias])
				return alias;

			var lines = this._data[alias].lines;
			for (i = 0; i < lines.length; i++)
			{
				content = lines[i].content;

				if (!result)
				{
					result = content;
				}
				else
				{
					result += separator + content;
				}
			}
		}
		return result;
	};

	/**
	 * Sets an array of line data as the current caption data to play.
	 * @private
	 * @method _load
	 * @param {String} data The string
	 */
	p._load = function(data)
	{
		if (this._destroyed) return;

		//Set the current playhead time
		this._reset();

		//make sure there is data to load, otherwise take it as an empty initialization
		if (!data)
		{
			this._lines = null;
			return;
		}
		this._lines = data.lines;
	};

	/**
	 * Reset the captions
	 * @private
	 * @method _reset
	 */
	p._reset = function()
	{
		this._currentLine = -1;
		this._lastActiveLine = -1;
	};

	/**
	 * Take the captions timecode and convert to milliseconds
	 * format is in HH:MM:ss:mmm
	 * @private
	 * @method _timeCodeToMilliseconds
	 * @param {String} input The input string of the format
	 * @return {int} Time in milliseconds
	 */
	function _timeCodeToMilliseconds(input)
	{
		var lastPeriodIndex = input.lastIndexOf(".");
		var ms = parseInt(input.substr(lastPeriodIndex + 1), 10);
		var parts = input.substr(0, lastPeriodIndex).split(":");
		var h = parseInt(parts[0], 10) * 3600000; //* 60 * 60 * 1000;
		var m = parseInt(parts[1], 10) * 6000; //* 60 * 1000;
		var s = parseInt(parts[2], 10) * 1000;

		return h + m + s + ms;
	}

	/**
	 * The playing status.
	 * @public
	 * @property {Boolean} playing
	 * @readOnly
	 */
	Object.defineProperty(p, 'playing',
	{
		get: function()
		{
			return this._playing;
		}
	});

	/**
	 * Calculate the total duration of the current caption
	 * @private
	 * @method _getTotalDuration
	 */
	p._getTotalDuration = function()
	{
		var lines = this._lines;
		return lines ? lines[lines.length - 1].end : 0;
	};

	/**
	 * Get the current duration of the current caption
	 * @property {int} currentDuration
	 * @readOnly
	 */
	Object.defineProperty(p, 'currentDuration',
	{
		get: function()
		{
			return this._currentDuration;
		}
	});

	/**
	 * Get the current caption alias.
	 * @property {String} currentAlias
	 * @readOnly
	 */
	Object.defineProperty(p, 'currentAlias',
	{
		get: function()
		{
			return this._currentAlias;
		}
	});

	/**
	 * Start the caption playback.
	 * @public
	 * @method play
	 * @param {String} alias The desired caption's alias
	 * @param {function} callback The function to call when the caption is finished playing
	 */
	p.play = function(alias, callback)
	{
		this.stop();
		this._completeCallback = callback;
		this._playing = true;
		this._currentAlias = alias;
		this._load(this._data[alias]);
		this._currentDuration = this._getTotalDuration();

		this.seek(0);
	};

	/**
	 * Convience function for stopping captions.
	 * @public
	 * @method stop
	 */
	p.stop = function()
	{
		this._playing = false;
		this._currentAlias = null;
		this._lines = null;
		this._completeCallback = null;
		this._reset();
		this._updateCaptions();
	};

	/**
	 * Goto a specific time.
	 * @public
	 * @method seek
	 * @param {int} time The time in milliseconds to seek to in the captions
	 */
	p.seek = function(time)
	{
		//Update the current time
		var currentTime = this._currentTime = time;

		var lines = this._lines;
		if (!lines)
		{
			this._updateCaptions();
			return;
		}

		if (currentTime < lines[0].start)
		{
			this._currentLine = this._lastActiveLine = -1;
			this._updateCaptions();
			return;
		}

		var len = lines.length;
		for (var i = 0; i < len; i++)
		{
			if (currentTime >= lines[i].start && currentTime <= lines[i].end)
			{
				this._currentLine = this._lastActiveLine = i;
				this._updateCaptions();
				break;
			}
			else if (currentTime > lines[i].end)
			{
				//this elseif helps us if there was no line at seek time,
				//so we can still keep track of the last active line
				this._lastActiveLine = i;
				this._currentLine = -1;
				this._updateCaptions();
			}
			else if (currentTime < lines[i].start)
			{
				//in between lines or before the first one
				this._lastActiveLine = i - 1;
				this._currentLine = -1;
				this._updateCaptions();
			}
		}
	};

	/**
	 * Callback for when a frame is entered.
	 * @private
	 * @method _updatePercent
	 * @param {number} progress The progress in the current sound as a percentage (0-1)
	 */
	p._updatePercent = function(progress)
	{
		if (this._destroyed)
			return;
		this._currentTime = progress * this._currentDuration;
		this._calcUpdate();
	};

	/**
	 * Function to update the amount of time elapsed for the caption playback.
	 * Call this to advance the caption by a given amount of time.
	 * @public
	 * @method update
	 * @param {int} progress The time elapsed since the last frame in milliseconds
	 */
	p.update = function(elapsed)
	{
		if (this._destroyed || !this._playing)
			return;
		this._currentTime += elapsed;
		this._calcUpdate();
	};

	/**
	 * Calculates the captions after increasing the current time.
	 * @private
	 * @method _calcUpdate
	 */
	p._calcUpdate = function()
	{
		var lines = this._lines;
		if (!lines)
			return;

		//Check for the end of the captions
		var len = lines.length;
		var nextLine = this._lastActiveLine + 1;
		var lastLine = len - 1;
		var currentTime = this._currentTime;

		//If we are outside of the bounds of captions, stop
		if (currentTime >= lines[lastLine].end)
		{
			this.stop();
		}
		else if (nextLine <= lastLine &&
			currentTime >= lines[nextLine].start &&
			currentTime <= lines[nextLine].end)
		{
			this._currentLine = this._lastActiveLine = nextLine;
			this._updateCaptions();
		}
		else if (this._currentLine != -1 &&
			currentTime > lines[this._currentLine].end)
		{
			this._lastActiveLine = this._currentLine;
			this._currentLine = -1;
			this._updateCaptions();
		}
	};

	/**
	 * Updates the text in the managed text field.
	 * @private
	 * @method _updateCaptions
	 */
	p._updateCaptions = function()
	{
		setText(
			this._textField, //
			(this._currentLine == -1 || this._mute) ? '' : this._lines[this._currentLine].content
		);
	};

	/**
	 * Returns duration in milliseconds of given captioned sound alias or alias list.
	 * @method getLength
	 * @param {String|Array} alias The alias or array of aliases for which to get duration.
	 *  Array may contain integers (milliseconds) to account for un-captioned gaps.
	 * @return {int} Length/duration of caption in milliseconds.
	 */
	p.getLength = function(alias)
	{
		var length = 0;
		if (Array.isArray(alias))
		{
			for (var i = 0, len = alias.length; i < len; i++)
			{
				if (typeof alias[i] == 'string')
				{
					length += this.getLength(alias[i]);
				}
				else if (typeof alias[i] == 'number')
				{
					length += alias[i];
				}
			}
		}
		else
		{
			if (!this._data[alias])
				return length;

			var lines = this._data[alias].lines;
			length += lines[lines.length - 1].end;
		}

		return parseInt(length);
	};

	/**
	 * Destroy this load task and don't use after this
	 * @method destroy
	 */
	p.destroy = function()
	{
		if (this._destroyed)
			return;

		this._destroyed = true;

		this._data = null;
		this._lines = null;
	};

	//assign to the namespacing
	namespace('springroll').Captions = Captions;

}());