'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 _events = require('../events'); var _events2 = _interopRequireDefault(_events); var _eventHandler = require('../event-handler'); var _eventHandler2 = _interopRequireDefault(_eventHandler); var _logger = require('../utils/logger'); var _errors = require('../errors'); 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 _possibleConstructorReturn(self, call) { if (!self) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return call && (typeof call === "object" || typeof call === "function") ? call : self; } 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; } /* * Buffer Controller */ var BufferController = function (_EventHandler) { _inherits(BufferController, _EventHandler); function BufferController(hls) { _classCallCheck(this, BufferController); // the value that we have set mediasource.duration to // (the actual duration may be tweaked slighly by the browser) var _this = _possibleConstructorReturn(this, (BufferController.__proto__ || Object.getPrototypeOf(BufferController)).call(this, hls, _events2.default.MEDIA_ATTACHING, _events2.default.MEDIA_DETACHING, _events2.default.MANIFEST_PARSED, _events2.default.BUFFER_RESET, _events2.default.BUFFER_APPENDING, _events2.default.BUFFER_CODECS, _events2.default.BUFFER_EOS, _events2.default.BUFFER_FLUSHING, _events2.default.LEVEL_PTS_UPDATED, _events2.default.LEVEL_UPDATED)); _this._msDuration = null; // the value that we want to set mediaSource.duration to _this._levelDuration = null; // Source Buffer listeners _this.onsbue = _this.onSBUpdateEnd.bind(_this); _this.onsbe = _this.onSBUpdateError.bind(_this); _this.pendingTracks = {}; _this.tracks = {}; return _this; } _createClass(BufferController, [{ key: 'destroy', value: function destroy() { _eventHandler2.default.prototype.destroy.call(this); } }, { key: 'onLevelPtsUpdated', value: function onLevelPtsUpdated(data) { var type = data.type; var audioTrack = this.tracks.audio; // Adjusting `SourceBuffer.timestampOffset` (desired point in the timeline where the next frames should be appended) // in Chrome browser when we detect MPEG audio container and time delta between level PTS and `SourceBuffer.timestampOffset` // is greater than 100ms (this is enough to handle seek for VOD or level change for LIVE videos). At the time of change we issue // `SourceBuffer.abort()` and adjusting `SourceBuffer.timestampOffset` if `SourceBuffer.updating` is false or awaiting `updateend` // event if SB is in updating state. // More info here: https://github.com/video-dev/hls.js/issues/332#issuecomment-257986486 if (type === 'audio' && audioTrack && audioTrack.container === 'audio/mpeg') { // Chrome audio mp3 track var audioBuffer = this.sourceBuffer.audio; var delta = Math.abs(audioBuffer.timestampOffset - data.start); // adjust timestamp offset if time delta is greater than 100ms if (delta > 0.1) { var updating = audioBuffer.updating; try { audioBuffer.abort(); } catch (err) { updating = true; _logger.logger.warn('can not abort audio buffer: ' + err); } if (!updating) { _logger.logger.warn('change mpeg audio timestamp offset from ' + audioBuffer.timestampOffset + ' to ' + data.start); audioBuffer.timestampOffset = data.start; } else { this.audioTimestampOffset = data.start; } } } } }, { key: 'onManifestParsed', value: function onManifestParsed(data) { var audioExpected = data.audio, videoExpected = data.video || data.levels.length && data.audio, sourceBufferNb = 0; // in case of alt audio 2 BUFFER_CODECS events will be triggered, one per stream controller // sourcebuffers will be created all at once when the expected nb of tracks will be reached // in case alt audio is not used, only one BUFFER_CODEC event will be fired from main stream controller // it will contain the expected nb of source buffers, no need to compute it if (data.altAudio && (audioExpected || videoExpected)) { sourceBufferNb = (audioExpected ? 1 : 0) + (videoExpected ? 1 : 0); _logger.logger.log(sourceBufferNb + ' sourceBuffer(s) expected'); } this.sourceBufferNb = sourceBufferNb; } }, { key: 'onMediaAttaching', value: function onMediaAttaching(data) { var media = this.media = data.media; if (media) { // setup the media source var ms = this.mediaSource = new MediaSource(); //Media Source listeners this.onmso = this.onMediaSourceOpen.bind(this); this.onmse = this.onMediaSourceEnded.bind(this); this.onmsc = this.onMediaSourceClose.bind(this); ms.addEventListener('sourceopen', this.onmso); ms.addEventListener('sourceended', this.onmse); ms.addEventListener('sourceclose', this.onmsc); // link video and media Source media.src = URL.createObjectURL(ms); } } }, { key: 'onMediaDetaching', value: function onMediaDetaching() { _logger.logger.log('media source detaching'); var ms = this.mediaSource; if (ms) { if (ms.readyState === 'open') { try { // endOfStream could trigger exception if any sourcebuffer is in updating state // we don't really care about checking sourcebuffer state here, // as we are anyway detaching the MediaSource // let's just avoid this exception to propagate ms.endOfStream(); } catch (err) { _logger.logger.warn('onMediaDetaching:' + err.message + ' while calling endOfStream'); } } ms.removeEventListener('sourceopen', this.onmso); ms.removeEventListener('sourceended', this.onmse); ms.removeEventListener('sourceclose', this.onmsc); // Detach properly the MediaSource from the HTMLMediaElement as // suggested in https://github.com/w3c/media-source/issues/53. if (this.media) { URL.revokeObjectURL(this.media.src); this.media.removeAttribute('src'); this.media.load(); } this.mediaSource = null; this.media = null; this.pendingTracks = {}; this.tracks = {}; this.sourceBuffer = {}; this.flushRange = []; this.segments = []; this.appended = 0; } this.onmso = this.onmse = this.onmsc = null; this.hls.trigger(_events2.default.MEDIA_DETACHED); } }, { key: 'onMediaSourceOpen', value: function onMediaSourceOpen() { _logger.logger.log('media source opened'); this.hls.trigger(_events2.default.MEDIA_ATTACHED, { media: this.media }); var mediaSource = this.mediaSource; if (mediaSource) { // once received, don't listen anymore to sourceopen event mediaSource.removeEventListener('sourceopen', this.onmso); } this.checkPendingTracks(); } }, { key: 'checkPendingTracks', value: function checkPendingTracks() { // if any buffer codecs pending, check if we have enough to create sourceBuffers var pendingTracks = this.pendingTracks, pendingTracksNb = Object.keys(pendingTracks).length; // if any pending tracks and (if nb of pending tracks gt or equal than expected nb or if unknown expected nb) if (pendingTracksNb && (this.sourceBufferNb <= pendingTracksNb || this.sourceBufferNb === 0)) { // ok, let's create them now ! this.createSourceBuffers(pendingTracks); this.pendingTracks = {}; // append any pending segments now ! this.doAppending(); } } }, { key: 'onMediaSourceClose', value: function onMediaSourceClose() { _logger.logger.log('media source closed'); } }, { key: 'onMediaSourceEnded', value: function onMediaSourceEnded() { _logger.logger.log('media source ended'); } }, { key: 'onSBUpdateEnd', value: function onSBUpdateEnd() { // update timestampOffset if (this.audioTimestampOffset) { var audioBuffer = this.sourceBuffer.audio; _logger.logger.warn('change mpeg audio timestamp offset from ' + audioBuffer.timestampOffset + ' to ' + this.audioTimestampOffset); audioBuffer.timestampOffset = this.audioTimestampOffset; delete this.audioTimestampOffset; } if (this._needsFlush) { this.doFlush(); } if (this._needsEos) { this.checkEos(); } this.appending = false; var parent = this.parent; // count nb of pending segments waiting for appending on this sourcebuffer var pending = this.segments.reduce(function (counter, segment) { return segment.parent === parent ? counter + 1 : counter; }, 0); this.hls.trigger(_events2.default.BUFFER_APPENDED, { parent: parent, pending: pending }); // don't append in flushing mode if (!this._needsFlush) { this.doAppending(); } this.updateMediaElementDuration(); } }, { key: 'onSBUpdateError', value: function onSBUpdateError(event) { _logger.logger.error('sourceBuffer error:', event); // according to http://www.w3.org/TR/media-source/#sourcebuffer-append-error // this error might not always be fatal (it is fatal if decode error is set, in that case // it will be followed by a mediaElement error ...) this.hls.trigger(_events2.default.ERROR, { type: _errors.ErrorTypes.MEDIA_ERROR, details: _errors.ErrorDetails.BUFFER_APPENDING_ERROR, fatal: false }); // we don't need to do more than that, as accordin to the spec, updateend will be fired just after } }, { key: 'onBufferReset', value: function onBufferReset() { var sourceBuffer = this.sourceBuffer; for (var type in sourceBuffer) { var sb = sourceBuffer[type]; try { this.mediaSource.removeSourceBuffer(sb); sb.removeEventListener('updateend', this.onsbue); sb.removeEventListener('error', this.onsbe); } catch (err) {} } this.sourceBuffer = {}; this.flushRange = []; this.segments = []; this.appended = 0; } }, { key: 'onBufferCodecs', value: function onBufferCodecs(tracks) { // if source buffer(s) not created yet, appended buffer tracks in this.pendingTracks // if sourcebuffers already created, do nothing ... if (Object.keys(this.sourceBuffer).length === 0) { for (var trackName in tracks) { this.pendingTracks[trackName] = tracks[trackName]; } var mediaSource = this.mediaSource; if (mediaSource && mediaSource.readyState === 'open') { // try to create sourcebuffers if mediasource opened this.checkPendingTracks(); } } } }, { key: 'createSourceBuffers', value: function createSourceBuffers(tracks) { var sourceBuffer = this.sourceBuffer, mediaSource = this.mediaSource; for (var trackName in tracks) { if (!sourceBuffer[trackName]) { var track = tracks[trackName]; // use levelCodec as first priority var codec = track.levelCodec || track.codec; var mimeType = track.container + ';codecs=' + codec; _logger.logger.log('creating sourceBuffer(' + mimeType + ')'); try { var sb = sourceBuffer[trackName] = mediaSource.addSourceBuffer(mimeType); sb.addEventListener('updateend', this.onsbue); sb.addEventListener('error', this.onsbe); this.tracks[trackName] = { codec: codec, container: track.container }; track.buffer = sb; } catch (err) { _logger.logger.error('error while trying to add sourceBuffer:' + err.message); this.hls.trigger(_events2.default.ERROR, { type: _errors.ErrorTypes.MEDIA_ERROR, details: _errors.ErrorDetails.BUFFER_ADD_CODEC_ERROR, fatal: false, err: err, mimeType: mimeType }); } } } this.hls.trigger(_events2.default.BUFFER_CREATED, { tracks: tracks }); } }, { key: 'onBufferAppending', value: function onBufferAppending(data) { if (!this._needsFlush) { if (!this.segments) { this.segments = [data]; } else { this.segments.push(data); } this.doAppending(); } } }, { key: 'onBufferAppendFail', value: function onBufferAppendFail(data) { _logger.logger.error('sourceBuffer error:', data.event); // according to http://www.w3.org/TR/media-source/#sourcebuffer-append-error // this error might not always be fatal (it is fatal if decode error is set, in that case // it will be followed by a mediaElement error ...) this.hls.trigger(_events2.default.ERROR, { type: _errors.ErrorTypes.MEDIA_ERROR, details: _errors.ErrorDetails.BUFFER_APPENDING_ERROR, fatal: false }); } // on BUFFER_EOS mark matching sourcebuffer(s) as ended and trigger checkEos() }, { key: 'onBufferEos', value: function onBufferEos(data) { var sb = this.sourceBuffer; var dataType = data.type; for (var type in sb) { if (!dataType || type === dataType) { if (!sb[type].ended) { sb[type].ended = true; _logger.logger.log(type + ' sourceBuffer now EOS'); } } } this.checkEos(); } // if all source buffers are marked as ended, signal endOfStream() to MediaSource. }, { key: 'checkEos', value: function checkEos() { var sb = this.sourceBuffer, mediaSource = this.mediaSource; if (!mediaSource || mediaSource.readyState !== 'open') { this._needsEos = false; return; } for (var type in sb) { var sbobj = sb[type]; if (!sbobj.ended) { return; } if (sbobj.updating) { this._needsEos = true; return; } } _logger.logger.log('all media data available, signal endOfStream() to MediaSource and stop loading fragment'); //Notify the media element that it now has all of the media data try { mediaSource.endOfStream(); } catch (e) { _logger.logger.warn('exception while calling mediaSource.endOfStream()'); } this._needsEos = false; } }, { key: 'onBufferFlushing', value: function onBufferFlushing(data) { this.flushRange.push({ start: data.startOffset, end: data.endOffset, type: data.type }); // attempt flush immediatly this.flushBufferCounter = 0; this.doFlush(); } }, { key: 'onLevelUpdated', value: function onLevelUpdated(event) { var details = event.details; if (details.fragments.length === 0) { return; } this._levelDuration = details.totalduration + details.fragments[0].start; this.updateMediaElementDuration(); } // https://github.com/video-dev/hls.js/issues/355 }, { key: 'updateMediaElementDuration', value: function updateMediaElementDuration() { var media = this.media, mediaSource = this.mediaSource, sourceBuffer = this.sourceBuffer, levelDuration = this._levelDuration; if (levelDuration === null || !media || !mediaSource || !sourceBuffer || media.readyState === 0 || mediaSource.readyState !== 'open') { return; } for (var type in sourceBuffer) { if (sourceBuffer[type].updating) { // can't set duration whilst a buffer is updating return; } } if (this._msDuration === null) { // initialise to the value that the media source is reporting this._msDuration = mediaSource.duration; } var duration = media.duration; // levelDuration was the last value we set. // not using mediaSource.duration as the browser may tweak this value // only update mediasource duration if its value increase, this is to avoid // flushing already buffered portion when switching between quality level if (levelDuration > this._msDuration && levelDuration > duration || duration === Infinity || isNaN(duration)) { _logger.logger.log('Updating mediasource duration to ' + levelDuration.toFixed(3)); this._msDuration = mediaSource.duration = levelDuration; } } }, { key: 'doFlush', value: function doFlush() { // loop through all buffer ranges to flush while (this.flushRange.length) { var range = this.flushRange[0]; // flushBuffer will abort any buffer append in progress and flush Audio/Video Buffer if (this.flushBuffer(range.start, range.end, range.type)) { // range flushed, remove from flush array this.flushRange.shift(); this.flushBufferCounter = 0; } else { this._needsFlush = true; // avoid looping, wait for SB update end to retrigger a flush return; } } if (this.flushRange.length === 0) { // everything flushed this._needsFlush = false; // let's recompute this.appended, which is used to avoid flush looping var appended = 0; var sourceBuffer = this.sourceBuffer; try { for (var type in sourceBuffer) { appended += sourceBuffer[type].buffered.length; } } catch (error) { // error could be thrown while accessing buffered, in case sourcebuffer has already been removed from MediaSource // this is harmess at this stage, catch this to avoid reporting an internal exception _logger.logger.error('error while accessing sourceBuffer.buffered'); } this.appended = appended; this.hls.trigger(_events2.default.BUFFER_FLUSHED); } } }, { key: 'doAppending', value: function doAppending() { var hls = this.hls, sourceBuffer = this.sourceBuffer, segments = this.segments; if (Object.keys(sourceBuffer).length) { if (this.media.error) { this.segments = []; _logger.logger.error('trying to append although a media error occured, flush segment and abort'); return; } if (this.appending) { //logger.log(`sb appending in progress`); return; } if (segments && segments.length) { var segment = segments.shift(); try { var type = segment.type, sb = sourceBuffer[type]; if (sb) { if (!sb.updating) { // reset sourceBuffer ended flag before appending segment sb.ended = false; //logger.log(`appending ${segment.content} ${type} SB, size:${segment.data.length}, ${segment.parent}`); this.parent = segment.parent; sb.appendBuffer(segment.data); this.appendError = 0; this.appended++; this.appending = true; } else { segments.unshift(segment); } } else { // in case we don't have any source buffer matching with this segment type, // it means that Mediasource fails to create sourcebuffer // discard this segment, and trigger update end this.onSBUpdateEnd(); } } catch (err) { // in case any error occured while appending, put back segment in segments table _logger.logger.error('error while trying to append buffer:' + err.message); segments.unshift(segment); var event = { type: _errors.ErrorTypes.MEDIA_ERROR, parent: segment.parent }; if (err.code !== 22) { if (this.appendError) { this.appendError++; } else { this.appendError = 1; } event.details = _errors.ErrorDetails.BUFFER_APPEND_ERROR; /* with UHD content, we could get loop of quota exceeded error until browser is able to evict some data from sourcebuffer. retrying help recovering this */ if (this.appendError > hls.config.appendErrorMaxRetry) { _logger.logger.log('fail ' + hls.config.appendErrorMaxRetry + ' times to append segment in sourceBuffer'); segments = []; event.fatal = true; hls.trigger(_events2.default.ERROR, event); return; } else { event.fatal = false; hls.trigger(_events2.default.ERROR, event); } } else { // QuotaExceededError: http://www.w3.org/TR/html5/infrastructure.html#quotaexceedederror // let's stop appending any segments, and report BUFFER_FULL_ERROR error this.segments = []; event.details = _errors.ErrorDetails.BUFFER_FULL_ERROR; event.fatal = false; hls.trigger(_events2.default.ERROR, event); return; } } } } } /* flush specified buffered range, return true once range has been flushed. as sourceBuffer.remove() is asynchronous, flushBuffer will be retriggered on sourceBuffer update end */ }, { key: 'flushBuffer', value: function flushBuffer(startOffset, endOffset, typeIn) { var sb, i, bufStart, bufEnd, flushStart, flushEnd, sourceBuffer = this.sourceBuffer; if (Object.keys(sourceBuffer).length) { _logger.logger.log('flushBuffer,pos/start/end: ' + this.media.currentTime.toFixed(3) + '/' + startOffset + '/' + endOffset); // safeguard to avoid infinite looping : don't try to flush more than the nb of appended segments if (this.flushBufferCounter < this.appended) { for (var type in sourceBuffer) { // check if sourcebuffer type is defined (typeIn): if yes, let's only flush this one // if no, let's flush all sourcebuffers if (typeIn && type !== typeIn) { continue; } sb = sourceBuffer[type]; // we are going to flush buffer, mark source buffer as 'not ended' sb.ended = false; if (!sb.updating) { try { for (i = 0; i < sb.buffered.length; i++) { bufStart = sb.buffered.start(i); bufEnd = sb.buffered.end(i); // workaround firefox not able to properly flush multiple buffered range. if (navigator.userAgent.toLowerCase().indexOf('firefox') !== -1 && endOffset === Number.POSITIVE_INFINITY) { flushStart = startOffset; flushEnd = endOffset; } else { flushStart = Math.max(bufStart, startOffset); flushEnd = Math.min(bufEnd, endOffset); } /* sometimes sourcebuffer.remove() does not flush the exact expected time range. to avoid rounding issues/infinite loop, only flush buffer range of length greater than 500ms. */ if (Math.min(flushEnd, bufEnd) - flushStart > 0.5) { this.flushBufferCounter++; _logger.logger.log('flush ' + type + ' [' + flushStart + ',' + flushEnd + '], of [' + bufStart + ',' + bufEnd + '], pos:' + this.media.currentTime); sb.remove(flushStart, flushEnd); return false; } } } catch (e) { _logger.logger.warn('exception while accessing sourcebuffer, it might have been removed from MediaSource'); } } else { //logger.log('abort ' + type + ' append in progress'); // this will abort any appending in progress //sb.abort(); _logger.logger.warn('cannot flush, sb updating in progress'); return false; } } } else { _logger.logger.warn('abort flushing too many retries'); } _logger.logger.log('buffer flushed'); } // everything flushed ! return true; } }]); return BufferController; }(_eventHandler2.default); exports.default = BufferController;