From 37ccbd70908e4a45924db9447daf4b0b5d03fcf7 Mon Sep 17 00:00:00 2001 From: Kenneth van der Werf Date: Tue, 23 Feb 2016 12:59:33 +0100 Subject: [PATCH 1/2] Reimplement subtitles --- lib/media/segmented_text_stream.js | 429 +++++++++++++++++ lib/media/text_tracks_manager.js | 642 +++++++++++++++++++++++++ lib/player/player.js | 5 +- lib/player/stream_video_source.js | 37 +- lib/util/web_vtt_parser.js | 730 +++++++++++++++++++++++++++++ 5 files changed, 1838 insertions(+), 5 deletions(-) create mode 100644 lib/media/segmented_text_stream.js create mode 100644 lib/media/text_tracks_manager.js create mode 100644 lib/util/web_vtt_parser.js diff --git a/lib/media/segmented_text_stream.js b/lib/media/segmented_text_stream.js new file mode 100644 index 0000000000..bc76cba377 --- /dev/null +++ b/lib/media/segmented_text_stream.js @@ -0,0 +1,429 @@ +/** + * Copyright 2014 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * @fileoverview Implements a segmented text stream. + */ +goog.require('shaka.media.IStream'); +goog.require('shaka.media.TextTracksManager'); +goog.require('shaka.util.FakeEventTarget'); +goog.require('shaka.util.PublicPromise'); + +goog.provide('shaka.media.SegmentedTextStream'); + + + +/** + * Segmented Text Stream for subtitles that come in chunks + * @param {shaka.player.StreamVideoSource} parent The parent object + * @param {HTMLVideoElement} video The video element + * @param {TextTrackList} textTracks The textTracks object + * @constructor + * @extends {shaka.util.FakeEventTarget} + * @implements {shaka.media.IStream} + */ +shaka.media.SegmentedTextStream = function(parent, video, textTracks) { + shaka.util.FakeEventTarget.call(this, parent); + + /** @private {HTMLVideoElement} */ + this.video_ = video; + + /** @private {boolean} */ + this.enabled_ = true; + + /** @private {shaka.media.StreamInfo} */ + this.streamInfo_ = null; + + /** @private {shaka.media.SegmentIndex} */ + this.segmentIndex_ = null; + + /** @private {!shaka.media.TextTracksManager} */ + this.ttm_ = + this.sbm_ = + new shaka.media.TextTracksManager(video, textTracks); + + /** @private {?number} */ + this.updateTimer_ = null; + + /** @private {!shaka.util.PublicPromise} */ + this.startedPromise_ = new shaka.util.PublicPromise(); + + /** @private {boolean} */ + this.resyncing_ = false; + + /** @private {?number} */ + this.timestampCorrection_ = null; + + /** @private {?number} */ + this.minBufferTime_ = 30; + + /** @private {?boolean} */ + this.needsNudge_ = true; + + /** @private {?boolean} */ + this.switched_ = false; + + if (!COMPILED) { + /** + * For debugging purposes. + * @private {boolean} + */ + this.fetching_ = false; + } +}; +goog.inherits(shaka.media.SegmentedTextStream, shaka.util.FakeEventTarget); + + +/** + * The amount of content to buffer, in seconds, after startup. + * @type {number} + */ +shaka.media.SegmentedTextStream.bufferSizeSeconds = 15.0; + + +/** + * Destroys the Stream. + */ +shaka.media.SegmentedTextStream.prototype.destroy = function() { +}; + + +/** @return {shaka.media.StreamInfo} */ +shaka.media.SegmentedTextStream.prototype.getStreamInfo = function() { + return this.streamInfo_; +}; + + +/** @return {shaka.media.SegmentIndex} */ +shaka.media.SegmentedTextStream.prototype.getSegmentIndex = function() { + return this.segmentIndex_; +}; + + +/** + * Returns a Promise that the Stream will resolve after it has begun presenting + * its first text stream. The Stream will never reject the returned Promise. + * + * @param {!Promise} proceed + * @return {!Promise.} + */ +shaka.media.SegmentedTextStream.prototype.started = function(proceed) { + return this.startedPromise_; +}; + + +/** @return {boolean} */ +shaka.media.SegmentedTextStream.prototype.hasEnded = function() { + return true; +}; + + +/** + * Updates the text stream + * + * @private + */ +shaka.media.SegmentedTextStream.prototype.onUpdate_ = function() { + shaka.asserts.assert(this.streamInfo_); + shaka.asserts.assert(this.segmentIndex_); + shaka.asserts.assert((this.updateTimer_ != null) && !this.resyncing_); + shaka.asserts.assert(!this.fetching_); + + this.updateTimer_ = null; + + var segmentIndex = + /** @type {!shaka.media.SegmentIndex} */ (this.segmentIndex_); + + var currentTime = this.video_.currentTime; + + + var bufferedAhead = this.ttm_.bufferedAheadOf(currentTime); + var bufferingGoal = this.getBufferingGoal(); + if (bufferedAhead >= bufferingGoal) { + shaka.log.v1('Buffering goal reached.'); + // TODO: trigger onUpdate_ when playback rate changes (assuming changed + // through Player.setPlaybackRate). + var rate = Math.abs(this.video_.playbackRate) || 1; + this.setUpdateTimer_(1000 / rate); + return; + } + + this.setEnabled(this.enabled_); + + var reference = this.getNext_(currentTime, segmentIndex); + if (!reference) { + shaka.log.v1('A new segment is not needed or is not available.'); + + // Check again in a second: the SegmentIndex might be generating + // SegmentReferences or there might be a manifest update. + this.setUpdateTimer_(1000); + return; + } + + if (!COMPILED) { + this.fetching_ = true; + } + + var fetch = this.ttm_.fetch(reference, this.streamInfo_); + fetch.then(shaka.util.TypedBind(this, + /** @param {?number} timestampCorrection */ + function(timestampCorrection) { + shaka.log.v1('Fetch done.'); + shaka.asserts.assert((this.updateTimer_ == null) && !this.resyncing_); + shaka.asserts.assert(this.fetching_); + + if (!COMPILED) { + this.fetching_ = false; + } + + //TODO: Do we need all of this? + if (this.timestampCorrection_ == null) { + shaka.asserts.assert(timestampCorrection != null); + this.timestampCorrection_ = timestampCorrection; + } + + this.setUpdateTimer_(0); + }) + ).catch(shaka.util.TypedBind(this, + /** @param {*} error */ + function(error) { + if (!COMPILED) { + this.fetching_ = false; + } + + if (error.type == 'aborted') { + // We were aborted from either destroy() or resync(). + shaka.log.v1('Fetch aborted.'); + shaka.asserts.assert(!this.ttm_ || this.resyncing_); + return; + } + + // Dispatch the event to the application. + var event = shaka.util.FakeEvent.createErrorEvent(error); + this.dispatchEvent(event); + + var recoverableErrors = [0, 404, 410]; + if (error.type == 'net' && + recoverableErrors.indexOf(error.xhr.status) != -1) { + shaka.log.debug('Calling onUpdate_() in 5 seconds...'); + // Depending on application policy, this could be recoverable, + // so set a timer on the supposition that the app might not end + // playback. + this.setUpdateTimer_(5000); + } + }) + ); +}; + + +/** + * Start or switch the stream to the given |streamInfo|. + * + * @override + */ +shaka.media.SegmentedTextStream.prototype.switch = function( + streamInfo, clearBuffer, opt_clearBufferOffset) { + + if (streamInfo == this.streamInfo_) { + shaka.log.debug('Already using stream', streamInfo); + return; + } + + streamInfo.segmentIndexSource.create() + .then(shaka.util.TypedBind(this, + function(result) { + var previousStreamInfo = this.streamInfo_; + + this.streamInfo_ = streamInfo; + this.segmentIndex_ = result; + this.switched_ = true; + this.setEnabled(true); + + if (this.resyncing_) { + // resync() was called while creating the SegmentIndex and init data. + // resync() will set the update timer if needed. + return; + } + + if (!previousStreamInfo) { + this.startedPromise_.resolve(0); + // Call onUpdate_() asynchronously so it can more easily assert that + // it was called at an appopriate time. + this.setUpdateTimer_(0); + } else { + shaka.asserts.assert((this.updateTimer_ != null) || this.fetching_); + } + + this.resync_(true /* forceClear */); + })).catch(shaka.util.TypedBind(this, + /** @param {*} error */ + function(error) { + if (error.type != 'aborted') { + var event = shaka.util.FakeEvent.createErrorEvent(error); + this.dispatchEvent(event); + } + })); +}; + + +/** + * Resync the stream with the video's currentTime. Called on seeking. + * @override + */ +shaka.media.SegmentedTextStream.prototype.resync = function() { + return this.resync_(false /* forceClear */); +}; + + +/** + * Enable or disable the stream. Not supported for all stream types. + * + * @param {boolean} enabled + */ +shaka.media.SegmentedTextStream.prototype.setEnabled = function(enabled) { + this.enabled_ = enabled; + var strId = (this.streamInfo_) ? this.streamInfo_.id : 'undefined'; + var objTrack = this.ttm_.getTextTrack(/** @type {string} */(strId)); + if (objTrack) { + objTrack.mode = enabled ? 'showing' : 'disabled'; + } +}; + + +/** + * @return {boolean} true if the stream is enabled. + */ +shaka.media.SegmentedTextStream.prototype.getEnabled = function() { + return this.enabled_; +}; + + +/** + * Resync the stream. + * + * @param {boolean} forceClear + * @private + */ +shaka.media.SegmentedTextStream.prototype.resync_ = function(forceClear) { + if (!this.streamInfo_ || this.resyncing_) { + return; + } + + shaka.asserts.assert((this.updateTimer_ != null) || this.fetching_); + + this.resyncing_ = true; + this.cancelUpdateTimer_(); + + this.ttm_.abort().then(shaka.util.TypedBind(this, + function() { + shaka.log.v1('Abort done.'); + shaka.asserts.assert((this.updateTimer_ == null) && this.resyncing_); + shaka.asserts.assert(!this.fetching_); + + // Clear the source buffer if we are seeking outside of the currently + // buffered range. This seems to make the browser's eviction policy + // saner and fixes "dead-zone" issues such as #15 and #26. If seeking + // within the buffered range, we avoid clearing so that we don't + // re-download content. + var currentTime = this.video_.currentTime; + if (forceClear || + !this.ttm_.isBuffered(currentTime) || + !this.ttm_.isInserted(currentTime)) { + shaka.log.debug('Nudge needed!'); + this.needsNudge_ = true; + return this.ttm_.clear(); + } else { + return Promise.resolve(); + } + }) + ).then(shaka.util.TypedBind(this, + function() { + shaka.asserts.assert((this.updateTimer_ == null) && this.resyncing_); + shaka.asserts.assert(!this.fetching_); + this.resyncing_ = false; + this.setUpdateTimer_(0); + }) + ).catch(shaka.util.TypedBind(this, + function(error) { + shaka.asserts.assert(error.type != 'aborted'); + this.resyncing_ = false; + + // Dispatch the event to the application. + var event = shaka.util.FakeEvent.createErrorEvent(error); + this.dispatchEvent(event); + }) + ); +}; + + +/** + * Dummy + * @param {Object} config + */ +shaka.media.SegmentedTextStream.prototype.configure = function(config) {}; + + +/** + * Gets the buffering goal. + * + * @return {number} + */ +shaka.media.SegmentedTextStream.prototype.getBufferingGoal = function() { + // If the stream is starting then consider the minimum buffer size. Since + // timestamp correction occurs after the stream has started it must be + // accounted for. + // TODO: Consider a different buffering goal when re-buffering. + return shaka.media.SegmentedTextStream.bufferSizeSeconds; +}; + + +/** + * Sets the update timer. + * + * @param {number} ms The timeout in milliseconds. + * @private + */ +shaka.media.SegmentedTextStream.prototype.setUpdateTimer_ = function(ms) { + shaka.asserts.assert(this.updateTimer_ == null); + this.updateTimer_ = window.setTimeout(this.onUpdate_.bind(this), ms); +}; + + +/** + * Cancels the update timer if it is running. + * + * @private + */ +shaka.media.SegmentedTextStream.prototype.cancelUpdateTimer_ = function() { + if (this.updateTimer_ != null) { + window.clearTimeout(this.updateTimer_); + this.updateTimer_ = null; + } +}; + + +/** + * Gets next segment + * @param {number} currentTime + * @param {Object} segmentIndex + * @return {shaka.media.SegmentReference} + * @private + */ +shaka.media.SegmentedTextStream.prototype.getNext_ = function( + currentTime, segmentIndex) { + var last = this.ttm_.getLastInserted(); + return last != null ? + (last.endTime != null ? segmentIndex.find(last.endTime) : null) : + segmentIndex.find(currentTime); +}; diff --git a/lib/media/text_tracks_manager.js b/lib/media/text_tracks_manager.js new file mode 100644 index 0000000000..19392fe603 --- /dev/null +++ b/lib/media/text_tracks_manager.js @@ -0,0 +1,642 @@ +/** + * @license + * Copyright 2015 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +goog.provide('shaka.media.TextTracksManager'); + +goog.require('shaka.asserts'); +goog.require('shaka.player.Defaults'); +goog.require('shaka.util.EventManager'); +goog.require('shaka.util.IBandwidthEstimator'); +goog.require('shaka.util.PublicPromise'); +goog.require('shaka.util.Task'); +goog.require('shaka.util.TypedBind'); +goog.require('shaka.util.WebVttParser'); + + + +/** + * Creates a manager for accessing the TextTracks Object + * @param {HTMLVideoElement} video + * @param {TextTrackList} textTracksSource + * @constructor + */ +shaka.media.TextTracksManager = function( + video, textTracksSource) { + + /** + * The video element + * @type {HTMLVideoElement} + * @private + */ + this.video_ = video; + + /** + * The textTracks source + * @type {TextTrackList} + * @private + */ + this.textTracksSource_ = textTracksSource; + + + /** + * The label of the text track to id by + * @type {string} + * @private + */ + this.textTracksLabel_ = ''; + + + /** + * Stream information + * @type {Object} + * @private + */ + this.streamInfo_ = {}; + + /** @private {shaka.util.IBandwidthEstimator} */ + this.estimator_ = null; + + /** @private {!shaka.util.EventManager} */ + this.eventManager_ = new shaka.util.EventManager(); + + /** + * Contains a list of segments that have been inserted into the SourceBuffer. + * These segments may or may not have been evicted by the browser. + * @private {!Array.} + */ + this.inserted_ = []; + + /** @private {number} */ + this.timestampCorrection_ = 0; + + /** @private {shaka.util.Task} */ + this.task_ = null; + + /** @private {shaka.util.PublicPromise} */ + this.operationPromise_ = null; + + /** @private {number} */ + this.segmentRequestTimeout_ = shaka.player.Defaults.SEGMENT_REQUEST_TIMEOUT; +}; + + +/** + * Destroys the TextTracksManager. + * @suppress {checkTypes} to set otherwise non-nullable types to null. + */ +shaka.media.TextTracksManager.prototype.destroy = function() { + this.abort().catch(function() {}); + + if (this.operationPromise_) { + this.operationPromise_.destroy(); + } + this.operationPromise_ = null; + this.task_ = null; + + this.inserted_ = null; + + this.eventManager_.destroy(); + this.eventManager_ = null; + + this.textTracksLabel_ = ''; + +}; + + +/** + * Checks if the given timestamp is buffered according to the virtual source + * buffer. + * + * Note that as a SegmentIndex may use PTS and a browser may use DTS or + * vice-versa, and due to MSE implementation details, isInserted(t) does not + * imply isBuffered(t) nor does isBuffered(t) imply isInserted(t). + * + * @param {number} timestamp The timestamp in seconds. + * @return {boolean} True if the timestamp is buffered. + */ +shaka.media.TextTracksManager.prototype.isInserted = function(timestamp) { + return shaka.media.SegmentReference.find(this.inserted_, timestamp) >= 0; +}; + + +/** + * Gets the SegmentReference corresponding to the last inserted segment. + * + * @return {shaka.media.SegmentReference} + */ +shaka.media.TextTracksManager.prototype.getLastInserted = function() { + var length = this.inserted_.length; + return length > 0 ? this.inserted_[length - 1] : null; +}; + + +/** + * Checks if the given timestamp is buffered according to the underlying + * SourceBuffer. + * + * @param {number} timestamp The timestamp in seconds. + * @return {boolean} True if the timestamp is buffered. + */ +shaka.media.TextTracksManager.prototype.isBuffered = function(timestamp) { + return this.bufferedAheadOf(timestamp) > 0; +}; + + +/** + * Computes how far ahead of the given timestamp we have buffered according to + * the underlying SourceBuffer. + * + * @param {number} timestamp The timestamp in seconds. + * @return {number} in seconds + */ +shaka.media.TextTracksManager.prototype.bufferedAheadOf = + function(timestamp) { + var objTrack; + var numDifference = 0; + var objLastTime; + if ((this.textTracksSource_ || []).length) { + try { + objTrack = this.getTextTrack(this.textTracksLabel_); + if ((objTrack) && objTrack.cues) { + shaka.asserts.assert(typeof timestamp === 'number'); + shaka.asserts.assert(objTrack.cues); + + objLastTime = Array.prototype.slice.call(objTrack.cues) + .sort(function(objA, objB) { + return objB.endTime - objA.endTime; + })[0]; + numDifference = objLastTime.endTime - timestamp; + } + } catch (e) { + shaka.log.debug('Could not figure out buffered ahead time:', e); + } + } + return numDifference; +}; + + +/** + * Fetches the segment corresponding to the given SegmentReference and appends + * the it to the underlying SourceBuffer. This cannot be called if another + * operation is in progress. + * + * @param {shaka.media.SegmentReference} reference + * @param {Object} streamInfo Optional information used for constructing + * textTracks information + * @return {!Promise.} A promise to a timestamp correction, which may + * be null if a timestamp correction could not be computed. A timestamp + * correction is computed if the underlying SourceBuffer is initially + * empty. The timestamp correction, if one is computed, is not + * automatically applied to the virtual source buffer; to apply a timestamp + * correction, call correct(). + */ +shaka.media.TextTracksManager.prototype.fetch = function( + reference, streamInfo) { + shaka.log.v1('fetch'); + + this.textTracksLabel_ = streamInfo.id; + + // Check state. + shaka.asserts.assert(!this.task_); + if (this.task_) { + var error = new Error('Cannot fetch: previous operation not complete.'); + error.type = 'stream'; + return Promise.reject(error); + } + + this.task_ = new shaka.util.Task(); + + this.task_.append( + function() { + var refDuration = + reference.endTime ? (reference.endTime - reference.startTime) : 1; + var params = new shaka.util.AjaxRequest.Parameters(); + params.maxAttempts = 3; + params.baseRetryDelayMs = refDuration * 1000; + params.requestTimeoutMs = this.segmentRequestTimeout_ * 1000; + params.responseType = 'text'; + return [ + reference.url.fetch(params, this.estimator_), + shaka.util.FailoverUri.prototype.abortFetch.bind(reference.url)]; + }.bind(this)); + + this.task_.append(shaka.util.TypedBind(this, + function(data) { + var append = this.append_.call(this, data, streamInfo); + return [append, this.abort_.bind(this)]; + })); + + this.task_.append( + function() { + var i = shaka.media.SegmentReference + .find(this.inserted_, reference.startTime); + if (i >= 0) { + // The SegmentReference at i has a start time less than |reference|'s. + this.inserted_.splice(i + 1, 0, reference); + } else { + this.inserted_.push(reference); + } + }.bind(this)); + + return this.startTask_().then( + function() { + return Promise.resolve(0); + }.bind(this)); +}; + + +/** + * Resets the virtual source buffer and clears all media from the underlying + * SourceBuffer. The returned promise will resolve immediately if there is no + * media within the underlying SourceBuffer. This cannot be called if another + * operation is in progress. + * + * @return {!Promise} + */ +shaka.media.TextTracksManager.prototype.clear = function() { + shaka.log.v1(this.logPrefix_(), 'clear'); + + // Check state. + shaka.asserts.assert(!this.task_); + if (this.task_) { + var error = new Error('Cannot clear (' + this.textTracksLabel_ + '): ' + + 'previous operation not complete.'); + error.type = 'stream'; + return Promise.reject(error); + } + + this.task_ = new shaka.util.Task(); + + this.task_.append(function() { + var p = this.clear_(); + return [p, this.abort_.bind(this)]; + }.bind(this)); + + return this.startTask_(); +}; + + +/** + * Resets the virtual source buffer and clears all media from the underlying + * SourceBuffer after the given timestamp. The returned promise will resolve + * immediately if there is no media within the underlying SourceBuffer. This + * cannot be called if another operation is in progress. + * + * @param {number} timestamp + * + * @return {!Promise} + */ +shaka.media.TextTracksManager.prototype.clearAfter = function(timestamp) { + shaka.log.v1(this.logPrefix_(), 'clearAfter'); + + // Check state. + shaka.asserts.assert(!this.task_); + if (this.task_) { + var error = new Error('Cannot clearAfter' + + '(' + this.textTracksLabel_ + '): ' + + 'previous operation not complete.'); + error.type = 'stream'; + return Promise.reject(error); + } + + this.task_ = new shaka.util.Task(); + + this.task_.append(function() { + var p = this.clearAfter_(timestamp); + return [p, this.abort_.bind(this)]; + }.bind(this)); + + return this.startTask_(); +}; + + +/** + * Aborts the current operation if one exists. + * The returned promise will never be rejected. + * + * @return {!Promise} + */ +shaka.media.TextTracksManager.prototype.abort = function() { + shaka.log.v1(this.logPrefix_(), 'abort'); + if (!this.task_) { + return Promise.resolve(); + } + return this.task_.abort(); +}; + + +/** + * Corrects each SegmentReference in the virtual source buffer by the given + * timestamp correction. The previous timestamp correction, if it exists, is + * replaced. + * + * @param {number} timestampCorrection + */ +shaka.media.TextTracksManager.prototype.correct = function( + timestampCorrection) { + var delta = timestampCorrection - this.timestampCorrection_; + if (delta == 0) { + return; + } + + this.inserted_ = shaka.media.SegmentReference.shift(this.inserted_, delta); + this.timestampCorrection_ = timestampCorrection; + + shaka.log.debug( + this.logPrefix_(), + 'applied timestamp correction of', + timestampCorrection, + 'seconds to TextTracksManager', + this); +}; + + +/** + * Emits an error message and returns true if there are multiple buffered + * ranges; otherwise, does nothing and returns false. + * + * @return {boolean} + */ +shaka.media.TextTracksManager.prototype.detectMultipleBufferedRanges = + function() { + return false; +}; + + +/** + * Sets the segment request timeout in seconds. + * + * @param {number} timeout + */ +shaka.media.TextTracksManager.prototype.setSegmentRequestTimeout = + function(timeout) { + shaka.asserts.assert(!isNaN(timeout)); + this.segmentRequestTimeout_ = timeout; +}; + + +/** + * Starts the task and returns a Promise which is resolved/rejected after the + * task ends and is cleaned up. + * + * @return {!Promise} + * @private + */ +shaka.media.TextTracksManager.prototype.startTask_ = function() { + shaka.asserts.assert(this.task_); + this.task_.start(); + return this.task_.getPromise().then(shaka.util.TypedBind(this, + function() { + this.task_ = null; + }) + ).catch(shaka.util.TypedBind(this, + /** @param {*} error */ + function(error) { + shaka.log.v1(this.logPrefix_(), 'task failed!'); + this.task_ = null; + return Promise.reject(error); + }) + ); +}; + + +/** + * Append to the text tracks object. + * + * @param {!String} data + * @param {Object} streamInfo + * @return {!Promise} + * @private + */ +shaka.media.TextTracksManager.prototype.append_ = function(data, streamInfo) { + shaka.asserts.assert(!this.operationPromise_); + shaka.asserts.assert(this.task_); + + try { + var strId = streamInfo.id; + var strLang = streamInfo.segmentIndexSource ? + streamInfo.segmentIndexSource.representation_.lang : ''; + var strSubtitles = data; + + var boolTrack = strSubtitles.indexOf('-->') !== -1; + var objTrack = this.addOrGetTextTrack(strId, strLang); + + if (boolTrack) { + var objParsed = new shaka.util.WebVttParser().parse(strSubtitles); + objParsed.cues.forEach(function(objCue) { + var objMatchedCue = + Array.prototype.slice.call(objTrack.cues || []).slice(-10) + .filter(function(objStoredCue) { + return objStoredCue.text === objCue.text; + }) + .slice(-1)[0]; + if (objMatchedCue) { + objMatchedCue.endTime = objCue.endTime; + shaka.log.debug('Editing parsed VTT cue', { + start: objMatchedCue.startTime, + end: objCue.endTime, + text: objMatchedCue.text + }); + } else { + objTrack + .addCue(new VTTCue(objCue.startTime, objCue.endTime, objCue.text)); + shaka.log.debug('Adding parsed VTT cue', { + start: objCue.startTime, + end: objCue.endTime, + text: objCue.text + }); + } + }); + } + + } catch (exception) { + shaka.log.debug('Failed to append buffer:', exception); + return Promise.reject(exception); + } + + setTimeout(function() { + this.onSourceBufferUpdateEnd_(new Event('someEvent')); + }.bind(this), 0); + + this.operationPromise_ = new shaka.util.PublicPromise(); + return this.operationPromise_; +}; + + +/** + * Clear the text tracks buffer. + * + * @return {!Promise} + * @private + */ +shaka.media.TextTracksManager.prototype.clear_ = function() { + shaka.asserts.assert(!this.operationPromise_); + + // TODO: Find a way to clear + if (this.textTracksSource_.length == 0) { + shaka.log.v1('Nothing to clear.'); + shaka.asserts.assert(this.inserted_.length == 0); + return Promise.resolve(); + } + + try { + // This will trigger an 'updateend' event. + this.clearTextTracks(); + this.inserted_ = []; + this.textTracksLabel_ = ''; + return Promise.resolve(); + } catch (exception) { + shaka.log.debug('Failed to clear buffer:', exception); + return Promise.reject(exception); + } +}; + + +/** + * Clear the text tracks buffer after the given timestamp (aligned to the next + * segment boundary). + * + * @param {number} timestamp + * + * @return {!Promise} + * @private + */ +shaka.media.TextTracksManager.prototype.clearAfter_ = function(timestamp) { + shaka.asserts.assert(!this.operationPromise_); + + var index = shaka.media.SegmentReference.find(this.inserted_, timestamp); + + // If no segment found, or it's the last one, bail out gracefully. + if (index == -1 || index == this.inserted_.length - 1) { + shaka.log.v1( + this.logPrefix_(), + 'nothing to clear: no segments on or after timestamp.'); + return Promise.resolve(); + } + + this.inserted_ = this.inserted_.slice(0, index + 1); + + this.operationPromise_ = new shaka.util.PublicPromise(); + return this.operationPromise_; +}; + + +/** + * Abort the current operation on the source buffer. + * + * @private + */ +shaka.media.TextTracksManager.prototype.abort_ = function() { + shaka.log.v1(this.logPrefix_(), 'abort_'); + shaka.asserts.assert(this.operationPromise_); +}; + + +/** + * |sourceBuffer_|'s 'updateend' callback. + * + * @param {!Event} event + * @private + */ +shaka.media.TextTracksManager.prototype.onSourceBufferUpdateEnd_ = + function(event) { + shaka.log.v1(this.logPrefix_(), 'onSourceBufferUpdateEnd_'); + + shaka.asserts.assert(this.operationPromise_); + + this.operationPromise_.resolve(); + this.operationPromise_ = null; +}; + + +/** + * Returns a text tracks from the text tracks list + * @param {string} strLabel + * @return {*} + */ +shaka.media.TextTracksManager.prototype.getTextTrack = function(strLabel) { + return this.findTrackByProperty('label', strLabel); +}; + + +/** + * Creates a text tracks and returns them or gets an existing one + * @param {string} strLabel + * @param {string} strLang + * @param {string=} opt_strKind + * @return {*} + */ +shaka.media.TextTracksManager.prototype.addOrGetTextTrack = + function(strLabel, strLang, opt_strKind) { + var strKindDefault = 'subtitles'; + var objTrack = this.getTextTrack(strLabel); + if (!objTrack) { + objTrack = this.video_ + .addTextTrack((opt_strKind || strKindDefault), strLabel, strLang); + } + return objTrack; +}; + + +/** + * Clears out the text tracks + * @return {boolean} + */ +shaka.media.TextTracksManager.prototype.clearTextTracks = function() { + if (!this.textTracksSource_) { + return false; + } + shaka.asserts.assert(this.textTracksSource_); + + Array.prototype.slice.call(this.textTracksSource_) + .forEach(function(objTextTrack) { + Array.prototype.slice.call(objTextTrack.cues || []) + .forEach(function(objCue) { + objTextTrack.removeCue(objCue); + }); + shaka.log.debug('Removed track cues:', objTextTrack); + }); +}; + + +/** + * Finds text by label e.g. + * @param {string } strKey + * @param {string} strValue + * @return {*} + */ +shaka.media.TextTracksManager.prototype.findTrackByProperty = + function(strKey, strValue) { + var arrFiltered = Array.prototype.slice.call(this.textTracksSource_) + .filter(function(objTrack) { + return objTrack[strKey] === strValue; + }); + return (arrFiltered.length) ? arrFiltered[0] : null; +}; + + +if (!COMPILED) { + /** + * Returns a string with the form 'SBM MIME_TYPE:' for logging purposes. + * + * @return {string} + * @private + */ + shaka.media.TextTracksManager.prototype.logPrefix_ = function() { + return 'TTM ' + this.textTracksLabel_ + ':'; + }; +} diff --git a/lib/player/player.js b/lib/player/player.js index af96decf09..bd3ce66aab 100644 --- a/lib/player/player.js +++ b/lib/player/player.js @@ -182,7 +182,10 @@ shaka.player.Player.isBrowserSupported = function() { shaka.player.Player.isTypeSupported = function(fullMimeType) { var supported; - if (fullMimeType == 'text/vtt') { + if ( + fullMimeType == 'text/vtt' || + fullMimeType == 'text/vtt; codecs="vtt"' + ) { supported = !!window.VTTCue; } else { supported = MediaSource.isTypeSupported(fullMimeType); diff --git a/lib/player/stream_video_source.js b/lib/player/stream_video_source.js index a72f8a1eb7..3333ab865d 100644 --- a/lib/player/stream_video_source.js +++ b/lib/player/stream_video_source.js @@ -25,6 +25,7 @@ goog.require('shaka.media.IStream'); goog.require('shaka.media.ManifestInfo'); goog.require('shaka.media.ManifestUpdater'); goog.require('shaka.media.PeriodInfo'); +goog.require('shaka.media.SegmentedTextStream'); goog.require('shaka.media.SimpleAbrManager'); goog.require('shaka.media.Stream'); goog.require('shaka.media.StreamInfo'); @@ -1473,9 +1474,19 @@ shaka.player.StreamVideoSource.prototype.createStreams_ = function( for (var type in streamInfosByType) { var streamInfo = streamInfosByType[type]; - var stream = type == 'text' ? - this.createTextStream_() : - this.createStream_(streamInfo); + var isTextStream = type === 'text'; + var isSegmented = streamInfo.segmentIndexSource.representation_ ? + !!streamInfo.segmentIndexSource.representation_.segmentTemplate : false; + var isSegmentedTextStream = isTextStream && isSegmented; + var stream; + + if (isSegmentedTextStream) { + stream = this.createSegmentedTextStream_(); + } else if (isTextStream) { + stream = this.createTextStream_(); + } else { + stream = this.createStream_(streamInfo); + } if (!stream) { var fullMimeType = streamInfo.getFullMimeType(); @@ -1516,6 +1527,25 @@ shaka.player.StreamVideoSource.prototype.createStream_ = function(streamInfo) { }; +/** + * Creates a text stream that is build up from segments/vtt cue strings + * + * @return {!shaka.media.SegmentedTextStream} + * @private + */ +shaka.player.StreamVideoSource.prototype.createSegmentedTextStream_ = + function() { + shaka.asserts.assert(this.video); + var video = /** @type {!HTMLVideoElement} */ (this.video); + var textTracks = /** @type {!TextTrackList} */ (video.textTracks); + return new shaka.media.SegmentedTextStream( + this, + video, + textTracks + ); +}; + + /** * Creates a TextStream object. * @@ -2388,4 +2418,3 @@ shaka.player.StreamVideoSource.prototype.configureStreams_ = function() { stream.configure(this.streamConfig_); } }; - diff --git a/lib/util/web_vtt_parser.js b/lib/util/web_vtt_parser.js new file mode 100644 index 0000000000..e2e38e1ccf --- /dev/null +++ b/lib/util/web_vtt_parser.js @@ -0,0 +1,730 @@ +// Any copyright is dedicated to the Public Domain. +// http://creativecommons.org/publicdomain/zero/1.0/ + +//https://github.com/annevk/webvtt +goog.provide('shaka.util.WebVttParser'); + +/** + * + * @constructor + */ +shaka.util.WebVttParser = function() { + /** + * + * @param {*=} input + * @param {*=} mode + * @return {{cues: Array, errors: Array, time: number}} + */ + this.parse = function(input, mode) { + //XXX need global search and replace for \0 + var NEWLINE = /\r\n|\r|\n/, + startTime = Date.now(), + linePos = 0, + lines = input.split(NEWLINE), + alreadyCollected = false, + cues = [], + errors = []; + + /** + * + * @param {*=} message + * @param {*=} col + */ + function err(message, col) { + errors.push({message:message, line:linePos+1, col:col}) + } + + var line = lines[linePos], + lineLength = line.length, + signature = "WEBVTT", + bom = 0, + signature_length = signature.length; + + /* Byte order mark */ + if (line[0] === "\ufeff") { + bom = 1; + signature_length += 1 + } + /* SIGNATURE */ + if ( + lineLength < signature_length || + line.indexOf(signature) !== 0+bom || + lineLength > signature_length && + line[signature_length] !== " " && + line[signature_length] !== "\t" + ) { + err("No valid signature. (File needs to start with \"WEBVTT\".)") + } + + linePos++; + + /* HEADER */ + while(lines[linePos] != "" && lines[linePos] != undefined) { + err("No blank line after the signature."); + if(lines[linePos].indexOf("-->") != -1) { + alreadyCollected = true; + break + } + linePos++ + } + + /* CUE LOOP */ + while(lines[linePos] != undefined) { + var cue; + while(!alreadyCollected && lines[linePos] == "") { + linePos++ + } + if(!alreadyCollected && lines[linePos] == undefined) + break; + + /* CUE CREATION */ + cue = { + id:"", + startTime:0, + endTime:0, + pauseOnExit:false, + direction:"horizontal", + snapToLines:true, + linePosition:"auto", + textPosition:50, + size:100, + alignment:"middle", + text:"", + tree:null + }; + + var parseTimings = true; + + if(lines[linePos].indexOf("-->") == -1) { + cue.id = lines[linePos]; + + /* COMMENTS + Not part of the specification's parser as these would just be ignored. However, + we want them to be conforming and not get "Cue identifier cannot be standalone". + */ + if(/^NOTE($|[ \t])/.test(cue.id)) { // .startsWith fails in Chrome + linePos++; + while(lines[linePos] != "" && lines[linePos] != undefined) { + if(lines[linePos].indexOf("-->") != -1) + err("Cannot have timestamp in a comment."); + linePos++ + } + continue + } + + linePos++; + + if(lines[linePos] == "" || lines[linePos] == undefined) { + err("Cue identifier cannot be standalone."); + continue + } + + if(lines[linePos].indexOf("-->") == -1) { + parseTimings = false; + err("Cue identifier needs to be followed by timestamp.") + } + } + + /* TIMINGS */ + alreadyCollected = false; + var timings = new shaka.util.WebVttParser.WebVTTCueTimingsAndSettingsParser(lines[linePos], err); + var previousCueStart = 0; + if(cues.length > 0) { + previousCueStart = cues[cues.length-1].startTime + } + if(parseTimings && !timings.parse(cue, previousCueStart)) { + /* BAD CUE */ + + cue = null; + linePos++; + + /* BAD CUE LOOP */ + while(lines[linePos] != "" && lines[linePos] != undefined) { + if(lines[linePos].indexOf("-->") != -1) { + alreadyCollected = true; + break + } + linePos++ + } + continue + } + linePos++; + + /* CUE TEXT LOOP */ + while(lines[linePos] != "" && lines[linePos] != undefined) { + if(lines[linePos].indexOf("-->") != -1) { + err("Blank line missing before cue."); + alreadyCollected = true; + break + } + if(cue.text != "") + cue.text += "\n"; + cue.text += lines[linePos]; + linePos++ + } + + /* CUE TEXT PROCESSING */ + var cuetextparser = new shaka.util.WebVttParser.WebVTTCueTextParser(cue.text, err, mode); + cue.tree = cuetextparser.parse(cue.startTime, cue.endTime); + cues.push(cue) + } + cues.sort(function(a, b) { + if (a.startTime < b.startTime) + return -1; + if (a.startTime > b.startTime) + return 1; + if (a.endTime > b.endTime) + return -1; + if (a.endTime < b.endTime) + return 1; + return 0 + }); + /* END */ + return {cues:cues, errors:errors, time:Date.now()-startTime} + } +}; + +/** + * + * @param line_ + * @param errorHandler + * @constructor + */ +shaka.util.WebVttParser.WebVTTCueTimingsAndSettingsParser = function(line_, errorHandler) { + var SPACE = /[\u0020\t\f]/, + NOSPACE = /[^\u0020\t\f]/, + line = line_, + pos = 0, + err = function(message) { + errorHandler(message, pos+1) + }, + spaceBeforeSetting = true; + function skip(pattern) { + while( + line[pos] != undefined && + pattern.test(line[pos]) + ) { + pos++ + } + } + function collect(pattern) { + var str = ""; + while( + line[pos] != undefined && + pattern.test(line[pos]) + ) { + str += line[pos]; + pos++ + } + return str + } + /* http://dev.w3.org/html5/webvtt/#collect-a-webvtt-timestamp */ + function timestamp() { + var units = "minutes", + val1, + val2, + val3, + val4; + // 3 + if(line[pos] == undefined) { + err("No timestamp found."); + return + } + // 4 + if(!/\d/.test(line[pos])) { + err("Timestamp must start with a character in the range 0-9."); + return + } + // 5-7 + val1 = collect(/\d/); + if(val1.length > 2 || parseInt(val1, 10) > 59) { + units = "hours" + } + // 8 + if(line[pos] != ":") { + err("No time unit separator found."); + return + } + pos++; + // 9-11 + val2 = collect(/\d/); + if(val2.length != 2) { + err("Must be exactly two digits."); + return + } + // 12 + if(units == "hours" || line[pos] == ":") { + if(line[pos] != ":") { + err("No seconds found or minutes is greater than 59."); + return + } + pos++; + val3 = collect(/\d/); + if(val3.length != 2) { + err("Must be exactly two digits."); + return + } + } else { + val3 = val2; + val2 = val1; + val1 = "0" + } + // 13 + if(line[pos] != ".") { + err("No decimal separator (\".\") found."); + return + } + pos++; + // 14-16 + val4 = collect(/\d/); + if(val4.length != 3) { + err("Milliseconds must be given in three digits."); + return + } + // 17 + if(parseInt(val2, 10) > 59) { + err("You cannot have more than 59 minutes."); + return + } + if(parseInt(val3, 10) > 59) { + err("You cannot have more than 59 seconds."); + return + } + return parseInt(val1, 10) * 60 * 60 + parseInt(val2, 10) * 60 + parseInt(val3, 10) + parseInt(val4, 10) / 1000 + } + + /* http://dev.w3.org/html5/webvtt/#parse-the-webvtt-settings */ + function parseSettings(input, cue) { + var settings = input.split(SPACE), + seen = []; + for(var i=0; i < settings.length; i++) { + if(settings[i] == "") + continue; + + var index = settings[i].indexOf(':'), + setting = settings[i].slice(0, index), + value = settings[i].slice(index + 1); + + if(seen.indexOf(setting) != -1) { + err("Duplicate setting.") + } + seen.push(setting); + + if(value == "") { + err("No value for setting defined."); + return + } + + if(setting == "vertical") { // writing direction + if(value != "rl" && value != "lr") { + err("Writing direction can only be set to 'rl' or 'rl'."); + continue + } + cue.direction = value + } else if(setting == "line") { // line position + if(!/\d/.test(value)) { + err("Line position takes a number or percentage."); + continue + } + if(value.indexOf("-", 1) != -1) { + err("Line position can only have '-' at the start."); + continue + } + if(value.indexOf("%") != -1 && value.indexOf("%") != value.length-1) { + err("Line position can only have '%' at the end."); + continue + } + if(value[0] == "-" && value[value.length-1] == "%") { + err("Line position cannot be a negative percentage."); + continue + } + if(value[value.length-1] == "%") { + if(parseInt(value, 10) > 100) { + err("Line position cannot be >100%."); + continue + } + cue.snapToLines = false + } + cue.linePosition = parseInt(value, 10) + } else if(setting == "position") { // text position + if(value[value.length-1] != "%") { + err("Text position must be a percentage."); + continue + } + if(parseInt(value, 10) > 100) { + err("Size cannot be >100%."); + continue + } + cue.textPosition = parseInt(value, 10) + } else if(setting == "size") { // size + if(value[value.length-1] != "%") { + err("Size must be a percentage."); + continue + } + if(parseInt(value, 10) > 100) { + err("Size cannot be >100%."); + continue + } + cue.size = parseInt(value, 10) + } else if(setting == "align") { // alignment + var alignValues = ["start", "middle", "end", "left", "right"]; + if(alignValues.indexOf(value) == -1) { + err("Alignment can only be set to one of " + alignValues.join(", ") + "."); + continue + } + cue.alignment = value + } else { + err("Invalid setting.") + } + } + } + + this.parse = function(cue, previousCueStart) { + skip(SPACE); + cue.startTime = timestamp(); + if(cue.startTime == undefined) { + return + } + if(cue.startTime < previousCueStart) { + err("Start timestamp is not greater than or equal to start timestamp of previous cue.") + } + if(NOSPACE.test(line[pos])) { + err("Timestamp not separated from '-->' by whitespace.") + } + skip(SPACE); + // 6-8 + if(line[pos] != "-") { + err("No valid timestamp separator found."); + return + } + pos++; + if(line[pos] != "-") { + err("No valid timestamp separator found."); + return + } + pos++; + if(line[pos] != ">") { + err("No valid timestamp separator found."); + return + } + pos++; + if(NOSPACE.test(line[pos])) { + err("'-->' not separated from timestamp by whitespace.") + } + skip(SPACE); + cue.endTime = timestamp(); + if(cue.endTime == undefined) { + return + } + if(cue.endTime <= cue.startTime) { + err("End timestamp is not greater than start timestamp.") + } + + if(NOSPACE.test(line[pos])) { + spaceBeforeSetting = false + } + skip(SPACE); + parseSettings(line.substring(pos), cue); + return true + }; + this.parseTimestamp = function() { + var ts = timestamp(); + if(line[pos] != undefined) { + err("Timestamp must not have trailing characters."); + return + } + return ts + } +}; + +/** + * + * @param line_ + * @param errorHandler + * @param mode + * @constructor + */ +shaka.util.WebVttParser.WebVTTCueTextParser = function(line_, errorHandler, mode) { + var line = line_, + pos = 0, + err = function(message) { + if(mode == "metadata") + return; + errorHandler(message, pos+1) + }; + + this.parse = function(cueStart, cueEnd) { + var result = {children:[]}, + current = result, + timestamps = []; + + function attach(token) { + current.children.push({type:"object", name:token[1], classes:token[2], children:[], parent:current}); + current = current.children[current.children.length-1] + } + function inScope(name) { + var node = current; + while(node) { + if(node.name == name) + return true; + node = node.parent + } + return + } + + while(line[pos] != undefined) { + var token = nextToken(); + if(token[0] == "text") { + current.children.push({type:"text", value:token[1], parent:current}) + } else if(token[0] == "start tag") { + if(mode == "chapters") + err("Start tags not allowed in chapter title text."); + var name = token[1]; + if(name != "v" && name != "lang" && token[3] != "") { + err("Only and can have an annotation.") + } + if( + name == "c" || + name == "i" || + name == "b" || + name == "u" || + name == "ruby" + ) { + attach(token) + } else if(name == "rt" && current.name == "ruby") { + attach(token) + } else if(name == "v") { + if(inScope("v")) { + err(" cannot be nested inside itself.") + } + attach(token); + current.value = token[3]; // annotation + if(!token[3]) { + err(" requires an annotation.") + } + } else if(name == "lang") { + attach(token); + current.value = token[3]; // language + } else { + err("Incorrect start tag.") + } + } else if(token[0] == "end tag") { + if(mode == "chapters") + err("End tags not allowed in chapter title text."); + // XXX check content + if(token[1] == current.name) { + current = current.parent + } else if(token[1] == "ruby" && current.name == "rt") { + current = current.parent.parent + } else { + err("Incorrect end tag.") + } + } else if(token[0] == "timestamp") { + if(mode == "chapters") + err("Timestamp not allowed in chapter title text."); + var timings = new shaka.util.WebVttParser.WebVTTCueTimingsAndSettingsParser(token[1], err), + timestamp = timings.parseTimestamp(); + if(timestamp != undefined) { + if(timestamp <= cueStart || timestamp >= cueEnd) { + err("Timestamp must be between start timestamp and end timestamp.") + } + if(timestamps.length > 0 && timestamps[timestamps.length-1] >= timestamp) { + err("Timestamp must be greater than any previous timestamp.") + } + current.children.push({type:"timestamp", value:timestamp, parent:current}); + timestamps.push(timestamp) + } + } + } + while(current.parent) { + if(current.name != "v") { + err("Required end tag missing.") + } + current = current.parent + } + return result + }; + + function nextToken() { + var state = "data", + result = "", + buffer = "", + classes = []; + while(line[pos-1] != undefined || pos == 0) { + var c = line[pos]; + if(state == "data") { + if(c == "&") { + buffer = c; + state = "escape" + } else if(c == "<" && result == "") { + state = "tag" + } else if(c == "<" || c == undefined) { + return ["text", result] + } else { + result += c + } + } else if(state == "escape") { + if(c == "&") { + err("Incorrect escape."); + result += buffer; + buffer = c + } else if(/[abglmnsprt]/.test(c)) { + buffer += c + } else if(c == ";") { + if(buffer == "&") { + result += "&" + } else if(buffer == "<") { + result += "<" + } else if(buffer == ">") { + result += ">" + } else if(buffer == "&lrm") { + result += "\u200e" + } else if(buffer == "&rlm") { + result += "\u200f" + } else if(buffer == " ") { + result += "\u00A0" + } else { + err("Incorrect escape."); + result += buffer + ";" + } + state = "data" + } else if(c == "<" || c == undefined) { + err("Incorrect escape."); + result += buffer; + return ["text", result] + } else { + err("Incorrect escape."); + result += buffer + c; + state = "data" + } + } else if(state == "tag") { + if(c == "\t" || c == "\n" || c == "\f" || c == " ") { + state = "start tag annotation" + } else if(c == ".") { + state = "start tag class" + } else if(c == "/") { + state = "end tag" + } else if(/\d/.test(c)) { + result = c; + state = "timestamp tag" + } else if(c == ">" || c == undefined) { + if(c == ">") { + pos++ + } + return ["start tag", "", [], ""] + } else { + result = c; + state = "start tag" + } + } else if(state == "start tag") { + if(c == "\t" || c == "\f" || c == " ") { + state = "start tag annotation" + } else if(c == "\n") { + buffer = c; + state = "start tag annotation" + } else if(c == ".") { + state = "start tag class" + } else if(c == ">" || c == undefined) { + if(c == ">") { + pos++ + } + return ["start tag", result, [], ""] + } else { + result += c + } + } else if(state == "start tag class") { + if(c == "\t" || c == "\f" || c == " ") { + classes.push(buffer); + buffer = ""; + state = "start tag annotation" + } else if(c == "\n") { + classes.push(buffer); + buffer = c; + state = "start tag annotation" + } else if(c == ".") { + classes.push(buffer); + buffer = "" + } else if(c == ">" || c == undefined) { + if(c == ">") { + pos++ + } + classes.push(buffer); + return ["start tag", result, classes, ""] + } else { + buffer += c + } + } else if(state == "start tag annotation") { + if(c == ">" || c == undefined) { + if(c == ">") { + pos++ + } + buffer = buffer.split(/[\u0020\t\f\r\n]+/).filter(function(item) { if(item) return true }).join(" "); + return ["start tag", result, classes, buffer] + } else { + buffer +=c + } + } else if(state == "end tag") { + if(c == ">" || c == undefined) { + if(c == ">") { + pos++ + } + return ["end tag", result] + } else { + result += c + } + } else if(state == "timestamp tag") { + if(c == ">" || c == undefined) { + if(c == ">") { + pos++ + } + return ["timestamp", result] + } else { + result += c + } + } else { + err("Never happens."); // The joke is it might. + } + // 8 + pos++ + } + } +}; + +shaka.util.WebVttParser.WebVTTSerializer = function() { + function serializeTree(tree) { + var result = ""; + for (var i = 0; i < tree.length; i++) { + var node = tree[i]; + if(node.type == "text") { + result += node.value + } else if(node.type == "object") { + result += "<" + node.name; + if(node.classes) { + for(var y = 0; y < node.classes.length; y++) { + result += "." + node.classes[y] + } + } + if(node.value) { + result += " " + node.value + } + result += ">"; + if(node.children) + result += serializeTree(node.children); + result += "" + } else { + result += "<" + node.value + ">" + } + } + return result + } + function serializeCue(cue) { + return cue.startTime + " " + cue.endTime + "\n" + serializeTree(cue.tree.children) + "\n\n" + } + this.serialize = function(cues) { + var result = ""; + for(var i=0;i Date: Wed, 24 Feb 2016 12:19:47 +0100 Subject: [PATCH 2/2] Added fixes around subtitles going nuts Fixed switching of subs to be consistent Added a check for showTextTrack --- lib/media/segmented_text_stream.js | 27 +++++++++++++------ lib/media/text_tracks_manager.js | 43 ++++++++++++++++++++++++++++++ lib/player/player.js | 6 ++--- lib/player/stream_video_source.js | 6 ++--- 4 files changed, 67 insertions(+), 15 deletions(-) diff --git a/lib/media/segmented_text_stream.js b/lib/media/segmented_text_stream.js index bc76cba377..3cd8accb66 100644 --- a/lib/media/segmented_text_stream.js +++ b/lib/media/segmented_text_stream.js @@ -89,7 +89,7 @@ goog.inherits(shaka.media.SegmentedTextStream, shaka.util.FakeEventTarget); * The amount of content to buffer, in seconds, after startup. * @type {number} */ -shaka.media.SegmentedTextStream.bufferSizeSeconds = 15.0; +shaka.media.SegmentedTextStream.bufferSizeSeconds = 25.0; /** @@ -159,7 +159,7 @@ shaka.media.SegmentedTextStream.prototype.onUpdate_ = function() { return; } - this.setEnabled(this.enabled_); + this.showTextTrack(this.streamInfo_, this.enabled_); var reference = this.getNext_(currentTime, segmentIndex); if (!reference) { @@ -237,6 +237,7 @@ shaka.media.SegmentedTextStream.prototype.switch = function( if (streamInfo == this.streamInfo_) { shaka.log.debug('Already using stream', streamInfo); + this.showTextTrack(this.streamInfo_, this.enabled_); return; } @@ -248,7 +249,8 @@ shaka.media.SegmentedTextStream.prototype.switch = function( this.streamInfo_ = streamInfo; this.segmentIndex_ = result; this.switched_ = true; - this.setEnabled(true); + this.showTextTrack(this.streamInfo_, this.enabled_); + this.showTextTrack(previousStreamInfo, false); if (this.resyncing_) { // resync() was called while creating the SegmentIndex and init data. @@ -293,11 +295,20 @@ shaka.media.SegmentedTextStream.prototype.resync = function() { */ shaka.media.SegmentedTextStream.prototype.setEnabled = function(enabled) { this.enabled_ = enabled; - var strId = (this.streamInfo_) ? this.streamInfo_.id : 'undefined'; - var objTrack = this.ttm_.getTextTrack(/** @type {string} */(strId)); - if (objTrack) { - objTrack.mode = enabled ? 'showing' : 'disabled'; - } + shaka.log.debug('Text Tracks setEnabled', this.enabled_); + this.showTextTrack(this.streamInfo_, this.enabled_); +}; + + +/** + * Delegates to ttm_ for showing track + * @param {Object} streamInfo The streamInfo containing an id + * @param {Boolean} enabled Show or hide tracks + */ +shaka.media.SegmentedTextStream.prototype.showTextTrack = + function(streamInfo, enabled) { + var strId = streamInfo && streamInfo.id || 'undefined'; + this.ttm_.showTextTrack(strId, enabled); }; diff --git a/lib/media/text_tracks_manager.js b/lib/media/text_tracks_manager.js index 19392fe603..e96ba5e403 100644 --- a/lib/media/text_tracks_manager.js +++ b/lib/media/text_tracks_manager.js @@ -59,6 +59,13 @@ shaka.media.TextTracksManager = function( */ this.textTracksLabel_ = ''; + /** + * Stores the state of the shown text track + * @type {?Object} + * @private + */ + this.shownTextTrackLabel_ = null; + /** * Stream information @@ -179,6 +186,8 @@ shaka.media.TextTracksManager.prototype.bufferedAheadOf = .sort(function(objA, objB) { return objB.endTime - objA.endTime; })[0]; + + console.debug(objLastTime, timestamp); numDifference = objLastTime.endTime - timestamp; } } catch (e) { @@ -613,6 +622,40 @@ shaka.media.TextTracksManager.prototype.clearTextTracks = function() { }; +/** + * Allows for a text track to be set to visible + * @param {String} id The id of the text track, aka label + * @param {Boolean} enabled Hide or show the text track specified + * @return {*} Returns false on not executing + */ +shaka.media.TextTracksManager.prototype.showTextTrack = function(id, enabled) { + var defaultMode = 'hidden'; + var givenMode = (enabled) ? 'showing' : defaultMode; + var mode = null; + if (id === 'undefined' && enabled) { + shaka.log.debug('Cannot change text track visibility of unknown'); + return false; + } + if (this.shownTextTrackLabel_ && + this.shownTextTrackLabel_.enabled === enabled && + this.shownTextTrackLabel_.id === id) { + return false; + } + this.shownTextTrackLabel_ = null; + Array.prototype.slice.call(this.textTracksSource_) + .forEach(function(textTrack) { + textTrack.mode = (textTrack.label === id) ? givenMode : defaultMode; + if (textTrack.label === id) { + this.shownTextTrackLabel_ = { + id: id, + enabled: enabled + }; + } + }.bind(this)); + shaka.log.debug('showTextTrack', this.shownTextTrackLabel_); +}; + + /** * Finds text by label e.g. * @param {string } strKey diff --git a/lib/player/player.js b/lib/player/player.js index bd3ce66aab..d88751e508 100644 --- a/lib/player/player.js +++ b/lib/player/player.js @@ -182,10 +182,8 @@ shaka.player.Player.isBrowserSupported = function() { shaka.player.Player.isTypeSupported = function(fullMimeType) { var supported; - if ( - fullMimeType == 'text/vtt' || - fullMimeType == 'text/vtt; codecs="vtt"' - ) { + if (fullMimeType == 'text/vtt' || + fullMimeType == 'text/vtt; codecs="vtt"') { supported = !!window.VTTCue; } else { supported = MediaSource.isTypeSupported(fullMimeType); diff --git a/lib/player/stream_video_source.js b/lib/player/stream_video_source.js index 3333ab865d..4be8778204 100644 --- a/lib/player/stream_video_source.js +++ b/lib/player/stream_video_source.js @@ -1481,11 +1481,11 @@ shaka.player.StreamVideoSource.prototype.createStreams_ = function( var stream; if (isSegmentedTextStream) { - stream = this.createSegmentedTextStream_(); + stream = this.createSegmentedTextStream_(); } else if (isTextStream) { - stream = this.createTextStream_(); + stream = this.createTextStream_(); } else { - stream = this.createStream_(streamInfo); + stream = this.createStream_(streamInfo); } if (!stream) {