/** * @file flash-source-buffer.js */ 'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); var _createClass = (function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ('value' in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; })(); var _get = function get(_x, _x2, _x3) { var _again = true; _function: while (_again) { var object = _x, property = _x2, receiver = _x3; _again = false; if (object === null) object = Function.prototype; var desc = Object.getOwnPropertyDescriptor(object, property); if (desc === undefined) { var parent = Object.getPrototypeOf(object); if (parent === null) { return undefined; } else { _x = parent; _x2 = property; _x3 = receiver; _again = true; desc = parent = undefined; continue _function; } } else if ('value' in desc) { return desc.value; } else { var getter = desc.get; if (getter === undefined) { return undefined; } return getter.call(receiver); } } }; function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { 'default': obj }; } function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError('Cannot call a class as a function'); } } function _inherits(subClass, superClass) { if (typeof superClass !== 'function' && superClass !== null) { throw new TypeError('Super expression must either be null or a function, not ' + typeof superClass); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, enumerable: false, writable: true, configurable: true } }); if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; } var _globalWindow = require('global/window'); var _globalWindow2 = _interopRequireDefault(_globalWindow); var _videoJs = require('video.js'); var _videoJs2 = _interopRequireDefault(_videoJs); var _muxJsLibFlv = require('mux.js/lib/flv'); var _muxJsLibFlv2 = _interopRequireDefault(_muxJsLibFlv); var _removeCuesFromTrack = require('./remove-cues-from-track'); var _removeCuesFromTrack2 = _interopRequireDefault(_removeCuesFromTrack); var _createTextTracksIfNecessary = require('./create-text-tracks-if-necessary'); var _createTextTracksIfNecessary2 = _interopRequireDefault(_createTextTracksIfNecessary); var _addTextTrackData = require('./add-text-track-data'); var _flashTransmuxerWorker = require('./flash-transmuxer-worker'); var _flashTransmuxerWorker2 = _interopRequireDefault(_flashTransmuxerWorker); var _webwackify = require('webwackify'); var _webwackify2 = _interopRequireDefault(_webwackify); var _flashConstants = require('./flash-constants'); var _flashConstants2 = _interopRequireDefault(_flashConstants); var resolveFlashTransmuxWorker = function resolveFlashTransmuxWorker() { var result = undefined; try { result = require.resolve('./flash-transmuxer-worker'); } catch (e) { // no result } return result; }; /** * A wrapper around the setTimeout function that uses * the flash constant time between ticks value. * * @param {Function} func the function callback to run * @private */ var scheduleTick = function scheduleTick(func) { // Chrome doesn't invoke requestAnimationFrame callbacks // in background tabs, so use setTimeout. _globalWindow2['default'].setTimeout(func, _flashConstants2['default'].TIME_BETWEEN_CHUNKS); }; /** * Generates a random string of max length 6 * * @return {String} the randomly generated string * @function generateRandomString * @private */ var generateRandomString = function generateRandomString() { return Math.random().toString(36).slice(2, 8); }; /** * Round a number to a specified number of places much like * toFixed but return a number instead of a string representation. * * @param {Number} num A number * @param {Number} places The number of decimal places which to * round * @private */ var toDecimalPlaces = function toDecimalPlaces(num, places) { if (typeof places !== 'number' || places < 0) { places = 0; } var scale = Math.pow(10, places); return Math.round(num * scale) / scale; }; /** * A SourceBuffer implementation for Flash rather than HTML. * * @link https://developer.mozilla.org/en-US/docs/Web/API/MediaSource * @param {Object} mediaSource the flash media source * @class FlashSourceBuffer * @extends videojs.EventTarget */ var FlashSourceBuffer = (function (_videojs$EventTarget) { _inherits(FlashSourceBuffer, _videojs$EventTarget); function FlashSourceBuffer(mediaSource) { var _this = this; _classCallCheck(this, FlashSourceBuffer); _get(Object.getPrototypeOf(FlashSourceBuffer.prototype), 'constructor', this).call(this); var encodedHeader = undefined; // Start off using the globally defined value but refine // as we append data into flash this.chunkSize_ = _flashConstants2['default'].BYTES_PER_CHUNK; // byte arrays queued to be appended this.buffer_ = []; // the total number of queued bytes this.bufferSize_ = 0; // to be able to determine the correct position to seek to, we // need to retain information about the mapping between the // media timeline and PTS values this.basePtsOffset_ = NaN; this.mediaSource_ = mediaSource; this.audioBufferEnd_ = NaN; this.videoBufferEnd_ = NaN; // indicates whether the asynchronous continuation of an operation // is still being processed // see https://w3c.github.io/media-source/#widl-SourceBuffer-updating this.updating = false; this.timestampOffset_ = 0; encodedHeader = _globalWindow2['default'].btoa(String.fromCharCode.apply(null, Array.prototype.slice.call(_muxJsLibFlv2['default'].getFlvHeader()))); // create function names with added randomness for the global callbacks flash will use // to get data from javascript into the swf. Random strings are added as a safety // measure for pages with multiple players since these functions will be global // instead of per instance. When making a call to the swf, the browser generates a // try catch code snippet, but just takes the function name and writes out an unquoted // call to that function. If the player id has any special characters, this will result // in an error, so safePlayerId replaces all special characters to '_' var safePlayerId = this.mediaSource_.player_.id().replace(/[^a-zA-Z0-9]/g, '_'); this.flashEncodedHeaderName_ = 'vjs_flashEncodedHeader_' + safePlayerId + generateRandomString(); this.flashEncodedDataName_ = 'vjs_flashEncodedData_' + safePlayerId + generateRandomString(); _globalWindow2['default'][this.flashEncodedHeaderName_] = function () { delete _globalWindow2['default'][_this.flashEncodedHeaderName_]; return encodedHeader; }; this.mediaSource_.swfObj.vjs_appendChunkReady(this.flashEncodedHeaderName_); this.transmuxer_ = (0, _webwackify2['default'])(_flashTransmuxerWorker2['default'], resolveFlashTransmuxWorker()); this.transmuxer_.postMessage({ action: 'init', options: {} }); this.transmuxer_.onmessage = function (event) { if (event.data.action === 'data') { _this.receiveBuffer_(event.data.segment); } }; this.one('updateend', function () { _this.mediaSource_.tech_.trigger('loadedmetadata'); }); Object.defineProperty(this, 'timestampOffset', { get: function get() { return this.timestampOffset_; }, set: function set(val) { if (typeof val === 'number' && val >= 0) { this.timestampOffset_ = val; // We have to tell flash to expect a discontinuity this.mediaSource_.swfObj.vjs_discontinuity(); // the media <-> PTS mapping must be re-established after // the discontinuity this.basePtsOffset_ = NaN; this.audioBufferEnd_ = NaN; this.videoBufferEnd_ = NaN; this.transmuxer_.postMessage({ action: 'reset' }); } } }); Object.defineProperty(this, 'buffered', { get: function get() { if (!this.mediaSource_ || !this.mediaSource_.swfObj || !('vjs_getProperty' in this.mediaSource_.swfObj)) { return _videoJs2['default'].createTimeRange(); } var buffered = this.mediaSource_.swfObj.vjs_getProperty('buffered'); if (buffered && buffered.length) { buffered[0][0] = toDecimalPlaces(buffered[0][0], 3); buffered[0][1] = toDecimalPlaces(buffered[0][1], 3); } return _videoJs2['default'].createTimeRanges(buffered); } }); // On a seek we remove all text track data since flash has no concept // of a buffered-range and everything else is reset on seek this.mediaSource_.player_.on('seeked', function () { (0, _removeCuesFromTrack2['default'])(0, Infinity, _this.metadataTrack_); if (_this.inbandTextTracks_) { for (var track in _this.inbandTextTracks_) { (0, _removeCuesFromTrack2['default'])(0, Infinity, _this.inbandTextTracks_[track]); } } }); var onHlsReset = this.onHlsReset_.bind(this); // hls-reset is fired by videojs.Hls on to the tech after the main SegmentLoader // resets its state and flushes the buffer this.mediaSource_.player_.tech_.on('hls-reset', onHlsReset); this.mediaSource_.player_.tech_.hls.on('dispose', function () { _this.transmuxer_.terminate(); _this.mediaSource_.player_.tech_.off('hls-reset', onHlsReset); }); } /** * Append bytes to the sourcebuffers buffer, in this case we * have to append it to swf object. * * @link https://developer.mozilla.org/en-US/docs/Web/API/SourceBuffer/appendBuffer * @param {Array} bytes */ _createClass(FlashSourceBuffer, [{ key: 'appendBuffer', value: function appendBuffer(bytes) { var error = undefined; if (this.updating) { error = new Error('SourceBuffer.append() cannot be called ' + 'while an update is in progress'); error.name = 'InvalidStateError'; error.code = 11; throw error; } this.updating = true; this.mediaSource_.readyState = 'open'; this.trigger({ type: 'update' }); this.transmuxer_.postMessage({ action: 'push', data: bytes.buffer, byteOffset: bytes.byteOffset, byteLength: bytes.byteLength }, [bytes.buffer]); this.transmuxer_.postMessage({ action: 'flush' }); } /** * Reset the parser and remove any data queued to be sent to the SWF. * * @link https://developer.mozilla.org/en-US/docs/Web/API/SourceBuffer/abort */ }, { key: 'abort', value: function abort() { this.buffer_ = []; this.bufferSize_ = 0; this.mediaSource_.swfObj.vjs_abort(); // report any outstanding updates have ended if (this.updating) { this.updating = false; this.trigger({ type: 'updateend' }); } } /** * Flash cannot remove ranges already buffered in the NetStream * but seeking clears the buffer entirely. For most purposes, * having this operation act as a no-op is acceptable. * * @link https://developer.mozilla.org/en-US/docs/Web/API/SourceBuffer/remove * @param {Double} start start of the section to remove * @param {Double} end end of the section to remove */ }, { key: 'remove', value: function remove(start, end) { (0, _removeCuesFromTrack2['default'])(start, end, this.metadataTrack_); if (this.inbandTextTracks_) { for (var track in this.inbandTextTracks_) { (0, _removeCuesFromTrack2['default'])(start, end, this.inbandTextTracks_[track]); } } this.trigger({ type: 'update' }); this.trigger({ type: 'updateend' }); } /** * Receive a buffer from the flv. * * @param {Object} segment * @private */ }, { key: 'receiveBuffer_', value: function receiveBuffer_(segment) { var _this2 = this; // create an in-band caption track if one is present in the segment (0, _createTextTracksIfNecessary2['default'])(this, this.mediaSource_, segment); (0, _addTextTrackData.addTextTrackData)(this, segment.captions, segment.metadata); // Do this asynchronously since convertTagsToData_ can be time consuming scheduleTick(function () { var flvBytes = _this2.convertTagsToData_(segment); if (_this2.buffer_.length === 0) { scheduleTick(_this2.processBuffer_.bind(_this2)); } if (flvBytes) { _this2.buffer_.push(flvBytes); _this2.bufferSize_ += flvBytes.byteLength; } }); } /** * Append a portion of the current buffer to the SWF. * * @private */ }, { key: 'processBuffer_', value: function processBuffer_() { var _this3 = this; var chunkSize = _flashConstants2['default'].BYTES_PER_CHUNK; if (!this.buffer_.length) { if (this.updating !== false) { this.updating = false; this.trigger({ type: 'updateend' }); } // do nothing if the buffer is empty return; } // concatenate appends up to the max append size var chunk = this.buffer_[0].subarray(0, chunkSize); // requeue any bytes that won't make it this round if (chunk.byteLength < chunkSize || this.buffer_[0].byteLength === chunkSize) { this.buffer_.shift(); } else { this.buffer_[0] = this.buffer_[0].subarray(chunkSize); } this.bufferSize_ -= chunk.byteLength; // base64 encode the bytes var binary = []; var length = chunk.byteLength; for (var i = 0; i < length; i++) { binary.push(String.fromCharCode(chunk[i])); } var b64str = _globalWindow2['default'].btoa(binary.join('')); _globalWindow2['default'][this.flashEncodedDataName_] = function () { // schedule another processBuffer to process any left over data or to // trigger updateend scheduleTick(_this3.processBuffer_.bind(_this3)); delete _globalWindow2['default'][_this3.flashEncodedDataName_]; return b64str; }; // Notify the swf that segment data is ready to be appended this.mediaSource_.swfObj.vjs_appendChunkReady(this.flashEncodedDataName_); } /** * Turns an array of flv tags into a Uint8Array representing the * flv data. Also removes any tags that are before the current * time so that playback begins at or slightly after the right * place on a seek * * @private * @param {Object} segmentData object of segment data */ }, { key: 'convertTagsToData_', value: function convertTagsToData_(segmentData) { var segmentByteLength = 0; var tech = this.mediaSource_.tech_; var videoTargetPts = 0; var segment = undefined; var videoTags = segmentData.tags.videoTags; var audioTags = segmentData.tags.audioTags; // Establish the media timeline to PTS translation if we don't // have one already if (isNaN(this.basePtsOffset_) && (videoTags.length || audioTags.length)) { // We know there is at least one video or audio tag, but since we may not have both, // we use pts: Infinity for the missing tag. The will force the following Math.min // call will to use the proper pts value since it will always be less than Infinity var firstVideoTag = videoTags[0] || { pts: Infinity }; var firstAudioTag = audioTags[0] || { pts: Infinity }; this.basePtsOffset_ = Math.min(firstAudioTag.pts, firstVideoTag.pts); } if (tech.seeking()) { // Do not use previously saved buffer end values while seeking since buffer // is cleared on all seeks this.videoBufferEnd_ = NaN; this.audioBufferEnd_ = NaN; } if (isNaN(this.videoBufferEnd_)) { if (tech.buffered().length) { videoTargetPts = tech.buffered().end(0) - this.timestampOffset; } // Trim to currentTime if seeking if (tech.seeking()) { videoTargetPts = Math.max(videoTargetPts, tech.currentTime() - this.timestampOffset); } // PTS values are represented in milliseconds videoTargetPts *= 1e3; videoTargetPts += this.basePtsOffset_; } else { // Add a fudge factor of 0.1 to the last video pts appended since a rendition change // could append an overlapping segment, in which case there is a high likelyhood // a tag could have a matching pts to videoBufferEnd_, which would cause // that tag to get appended by the tag.pts >= targetPts check below even though it // is a duplicate of what was previously appended videoTargetPts = this.videoBufferEnd_ + 0.1; } // filter complete GOPs with a presentation time less than the seek target/end of buffer var currentIndex = videoTags.length; // if the last tag is beyond videoTargetPts, then do not search the list for a GOP // since our videoTargetPts lies in a future segment if (currentIndex && videoTags[currentIndex - 1].pts >= videoTargetPts) { // Start by walking backwards from the end of the list until we reach a tag that // is equal to or less than videoTargetPts while (--currentIndex) { var currentTag = videoTags[currentIndex]; if (currentTag.pts > videoTargetPts) { continue; } // if we see a keyFrame or metadata tag once we've gone below videoTargetPts, // exit the loop as this is the start of the GOP that we want to append if (currentTag.keyFrame || currentTag.metaDataTag) { break; } } // We need to check if there are any metadata tags that come before currentIndex // as those will be metadata tags associated with the GOP we are appending // There could be 0 to 2 metadata tags that come before the currentIndex depending // on what videoTargetPts is and whether the transmuxer prepended metadata tags to this // key frame while (currentIndex) { var nextTag = videoTags[currentIndex - 1]; if (!nextTag.metaDataTag) { break; } currentIndex--; } } var filteredVideoTags = videoTags.slice(currentIndex); var audioTargetPts = undefined; if (isNaN(this.audioBufferEnd_)) { audioTargetPts = videoTargetPts; } else { // Add a fudge factor of 0.1 to the last video pts appended since a rendition change // could append an overlapping segment, in which case there is a high likelyhood // a tag could have a matching pts to videoBufferEnd_, which would cause // that tag to get appended by the tag.pts >= targetPts check below even though it // is a duplicate of what was previously appended audioTargetPts = this.audioBufferEnd_ + 0.1; } if (filteredVideoTags.length) { // If targetPts intersects a GOP and we appended the tags for the GOP that came // before targetPts, we want to make sure to trim audio tags at the pts // of the first video tag to avoid brief moments of silence audioTargetPts = Math.min(audioTargetPts, filteredVideoTags[0].pts); } // skip tags with a presentation time less than the seek target/end of buffer currentIndex = 0; while (currentIndex < audioTags.length) { if (audioTags[currentIndex].pts >= audioTargetPts) { break; } currentIndex++; } var filteredAudioTags = audioTags.slice(currentIndex); // update the audio and video buffer ends if (filteredAudioTags.length) { this.audioBufferEnd_ = filteredAudioTags[filteredAudioTags.length - 1].pts; } if (filteredVideoTags.length) { this.videoBufferEnd_ = filteredVideoTags[filteredVideoTags.length - 1].pts; } var tags = this.getOrderedTags_(filteredVideoTags, filteredAudioTags); if (tags.length === 0) { return; } // If we are appending data that comes before our target pts, we want to tell // the swf to adjust its notion of current time to account for the extra tags // we are appending to complete the GOP that intersects with targetPts if (tags[0].pts < videoTargetPts && tech.seeking()) { var fudgeFactor = 1 / 30; var currentTime = tech.currentTime(); var diff = (videoTargetPts - tags[0].pts) / 1e3; var adjustedTime = currentTime - diff; if (adjustedTime < fudgeFactor) { adjustedTime = 0; } try { this.mediaSource_.swfObj.vjs_adjustCurrentTime(adjustedTime); } catch (e) { // no-op for backwards compatability of swf. If adjustCurrentTime fails, // the swf may incorrectly report currentTime and buffered ranges // but should not affect playback over than the time displayed on the // progress bar is inaccurate } } // concatenate the bytes into a single segment for (var i = 0; i < tags.length; i++) { segmentByteLength += tags[i].bytes.byteLength; } segment = new Uint8Array(segmentByteLength); for (var i = 0, j = 0; i < tags.length; i++) { segment.set(tags[i].bytes, j); j += tags[i].bytes.byteLength; } return segment; } /** * Assemble the FLV tags in decoder order. * * @private * @param {Array} videoTags list of video tags * @param {Array} audioTags list of audio tags */ }, { key: 'getOrderedTags_', value: function getOrderedTags_(videoTags, audioTags) { var tag = undefined; var tags = []; while (videoTags.length || audioTags.length) { if (!videoTags.length) { // only audio tags remain tag = audioTags.shift(); } else if (!audioTags.length) { // only video tags remain tag = videoTags.shift(); } else if (audioTags[0].dts < videoTags[0].dts) { // audio should be decoded next tag = audioTags.shift(); } else { // video should be decoded next tag = videoTags.shift(); } tags.push(tag); } return tags; } }, { key: 'onHlsReset_', value: function onHlsReset_() { this.transmuxer_.postMessage({ action: 'resetCaptions' }); } }]); return FlashSourceBuffer; })(_videoJs2['default'].EventTarget); exports['default'] = FlashSourceBuffer; module.exports = exports['default'];