358 lines
21 KiB
JavaScript
358 lines
21 KiB
JavaScript
|
|
'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 _bufferHelper = require('../helper/buffer-helper');
|
||
|
|
|
||
|
|
var _bufferHelper2 = _interopRequireDefault(_bufferHelper);
|
||
|
|
|
||
|
|
var _errors = require('../errors');
|
||
|
|
|
||
|
|
var _logger = require('../utils/logger');
|
||
|
|
|
||
|
|
var _ewmaBandwidthEstimator = require('../utils/ewma-bandwidth-estimator');
|
||
|
|
|
||
|
|
var _ewmaBandwidthEstimator2 = _interopRequireDefault(_ewmaBandwidthEstimator);
|
||
|
|
|
||
|
|
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; } /*
|
||
|
|
* simple ABR Controller
|
||
|
|
* - compute next level based on last fragment bw heuristics
|
||
|
|
* - implement an abandon rules triggered if we have less than 2 frag buffered and if computed bw shows that we risk buffer stalling
|
||
|
|
*/
|
||
|
|
|
||
|
|
var AbrController = function (_EventHandler) {
|
||
|
|
_inherits(AbrController, _EventHandler);
|
||
|
|
|
||
|
|
function AbrController(hls) {
|
||
|
|
_classCallCheck(this, AbrController);
|
||
|
|
|
||
|
|
var _this = _possibleConstructorReturn(this, (AbrController.__proto__ || Object.getPrototypeOf(AbrController)).call(this, hls, _events2.default.FRAG_LOADING, _events2.default.FRAG_LOADED, _events2.default.FRAG_BUFFERED, _events2.default.ERROR));
|
||
|
|
|
||
|
|
_this.lastLoadedFragLevel = 0;
|
||
|
|
_this._nextAutoLevel = -1;
|
||
|
|
_this.hls = hls;
|
||
|
|
_this.timer = null;
|
||
|
|
_this._bwEstimator = null;
|
||
|
|
_this.onCheck = _this._abandonRulesCheck.bind(_this);
|
||
|
|
return _this;
|
||
|
|
}
|
||
|
|
|
||
|
|
_createClass(AbrController, [{
|
||
|
|
key: 'destroy',
|
||
|
|
value: function destroy() {
|
||
|
|
this.clearTimer();
|
||
|
|
_eventHandler2.default.prototype.destroy.call(this);
|
||
|
|
}
|
||
|
|
}, {
|
||
|
|
key: 'onFragLoading',
|
||
|
|
value: function onFragLoading(data) {
|
||
|
|
var frag = data.frag;
|
||
|
|
if (frag.type === 'main') {
|
||
|
|
if (!this.timer) {
|
||
|
|
this.timer = setInterval(this.onCheck, 100);
|
||
|
|
}
|
||
|
|
// lazy init of bw Estimator, rationale is that we use different params for Live/VoD
|
||
|
|
// so we need to wait for stream manifest / playlist type to instantiate it.
|
||
|
|
if (!this._bwEstimator) {
|
||
|
|
var hls = this.hls,
|
||
|
|
level = data.frag.level,
|
||
|
|
isLive = hls.levels[level].details.live,
|
||
|
|
config = hls.config,
|
||
|
|
ewmaFast = void 0,
|
||
|
|
ewmaSlow = void 0;
|
||
|
|
|
||
|
|
if (isLive) {
|
||
|
|
ewmaFast = config.abrEwmaFastLive;
|
||
|
|
ewmaSlow = config.abrEwmaSlowLive;
|
||
|
|
} else {
|
||
|
|
ewmaFast = config.abrEwmaFastVoD;
|
||
|
|
ewmaSlow = config.abrEwmaSlowVoD;
|
||
|
|
}
|
||
|
|
this._bwEstimator = new _ewmaBandwidthEstimator2.default(hls, ewmaSlow, ewmaFast, config.abrEwmaDefaultEstimate);
|
||
|
|
}
|
||
|
|
this.fragCurrent = frag;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}, {
|
||
|
|
key: '_abandonRulesCheck',
|
||
|
|
value: function _abandonRulesCheck() {
|
||
|
|
/*
|
||
|
|
monitor fragment retrieval time...
|
||
|
|
we compute expected time of arrival of the complete fragment.
|
||
|
|
we compare it to expected time of buffer starvation
|
||
|
|
*/
|
||
|
|
var hls = this.hls,
|
||
|
|
v = hls.media,
|
||
|
|
frag = this.fragCurrent,
|
||
|
|
loader = frag.loader,
|
||
|
|
minAutoLevel = hls.minAutoLevel;
|
||
|
|
|
||
|
|
// if loader has been destroyed or loading has been aborted, stop timer and return
|
||
|
|
if (!loader || loader.stats && loader.stats.aborted) {
|
||
|
|
_logger.logger.warn('frag loader destroy or aborted, disarm abandonRules');
|
||
|
|
this.clearTimer();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
var stats = loader.stats;
|
||
|
|
/* only monitor frag retrieval time if
|
||
|
|
(video not paused OR first fragment being loaded(ready state === HAVE_NOTHING = 0)) AND autoswitching enabled AND not lowest level (=> means that we have several levels) */
|
||
|
|
if (v && stats && (!v.paused && v.playbackRate !== 0 || !v.readyState) && frag.autoLevel && frag.level) {
|
||
|
|
var requestDelay = performance.now() - stats.trequest,
|
||
|
|
playbackRate = Math.abs(v.playbackRate);
|
||
|
|
// monitor fragment load progress after half of expected fragment duration,to stabilize bitrate
|
||
|
|
if (requestDelay > 500 * frag.duration / playbackRate) {
|
||
|
|
var levels = hls.levels,
|
||
|
|
loadRate = Math.max(1, stats.bw ? stats.bw / 8 : stats.loaded * 1000 / requestDelay),
|
||
|
|
// byte/s; at least 1 byte/s to avoid division by zero
|
||
|
|
// compute expected fragment length using frag duration and level bitrate. also ensure that expected len is gte than already loaded size
|
||
|
|
level = levels[frag.level],
|
||
|
|
levelBitrate = level.realBitrate ? Math.max(level.realBitrate, level.bitrate) : level.bitrate,
|
||
|
|
expectedLen = stats.total ? stats.total : Math.max(stats.loaded, Math.round(frag.duration * levelBitrate / 8)),
|
||
|
|
pos = v.currentTime,
|
||
|
|
fragLoadedDelay = (expectedLen - stats.loaded) / loadRate,
|
||
|
|
bufferStarvationDelay = (_bufferHelper2.default.bufferInfo(v, pos, hls.config.maxBufferHole).end - pos) / playbackRate;
|
||
|
|
// consider emergency switch down only if we have less than 2 frag buffered AND
|
||
|
|
// time to finish loading current fragment is bigger than buffer starvation delay
|
||
|
|
// ie if we risk buffer starvation if bw does not increase quickly
|
||
|
|
if (bufferStarvationDelay < 2 * frag.duration / playbackRate && fragLoadedDelay > bufferStarvationDelay) {
|
||
|
|
var fragLevelNextLoadedDelay = void 0,
|
||
|
|
nextLoadLevel = void 0;
|
||
|
|
// lets iterate through lower level and try to find the biggest one that could avoid rebuffering
|
||
|
|
// we start from current level - 1 and we step down , until we find a matching level
|
||
|
|
for (nextLoadLevel = frag.level - 1; nextLoadLevel > minAutoLevel; nextLoadLevel--) {
|
||
|
|
// compute time to load next fragment at lower level
|
||
|
|
// 0.8 : consider only 80% of current bw to be conservative
|
||
|
|
// 8 = bits per byte (bps/Bps)
|
||
|
|
var levelNextBitrate = levels[nextLoadLevel].realBitrate ? Math.max(levels[nextLoadLevel].realBitrate, levels[nextLoadLevel].bitrate) : levels[nextLoadLevel].bitrate;
|
||
|
|
fragLevelNextLoadedDelay = frag.duration * levelNextBitrate / (8 * 0.8 * loadRate);
|
||
|
|
if (fragLevelNextLoadedDelay < bufferStarvationDelay) {
|
||
|
|
// we found a lower level that be rebuffering free with current estimated bw !
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
// only emergency switch down if it takes less time to load new fragment at lowest level instead
|
||
|
|
// of finishing loading current one ...
|
||
|
|
if (fragLevelNextLoadedDelay < fragLoadedDelay) {
|
||
|
|
_logger.logger.warn('loading too slow, abort fragment loading and switch to level ' + nextLoadLevel + ':fragLoadedDelay[' + nextLoadLevel + ']<fragLoadedDelay[' + (frag.level - 1) + '];bufferStarvationDelay:' + fragLevelNextLoadedDelay.toFixed(1) + '<' + fragLoadedDelay.toFixed(1) + ':' + bufferStarvationDelay.toFixed(1));
|
||
|
|
// force next load level in auto mode
|
||
|
|
hls.nextLoadLevel = nextLoadLevel;
|
||
|
|
// update bw estimate for this fragment before cancelling load (this will help reducing the bw)
|
||
|
|
this._bwEstimator.sample(requestDelay, stats.loaded);
|
||
|
|
//abort fragment loading
|
||
|
|
loader.abort();
|
||
|
|
// stop abandon rules timer
|
||
|
|
this.clearTimer();
|
||
|
|
hls.trigger(_events2.default.FRAG_LOAD_EMERGENCY_ABORTED, { frag: frag, stats: stats });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}, {
|
||
|
|
key: 'onFragLoaded',
|
||
|
|
value: function onFragLoaded(data) {
|
||
|
|
var frag = data.frag;
|
||
|
|
if (frag.type === 'main' && !isNaN(frag.sn)) {
|
||
|
|
// stop monitoring bw once frag loaded
|
||
|
|
this.clearTimer();
|
||
|
|
// store level id after successful fragment load
|
||
|
|
this.lastLoadedFragLevel = frag.level;
|
||
|
|
// reset forced auto level value so that next level will be selected
|
||
|
|
this._nextAutoLevel = -1;
|
||
|
|
|
||
|
|
// compute level average bitrate
|
||
|
|
if (this.hls.config.abrMaxWithRealBitrate) {
|
||
|
|
var level = this.hls.levels[frag.level];
|
||
|
|
var loadedBytes = (level.loaded ? level.loaded.bytes : 0) + data.stats.loaded;
|
||
|
|
var loadedDuration = (level.loaded ? level.loaded.duration : 0) + data.frag.duration;
|
||
|
|
level.loaded = { bytes: loadedBytes, duration: loadedDuration };
|
||
|
|
level.realBitrate = Math.round(8 * loadedBytes / loadedDuration);
|
||
|
|
}
|
||
|
|
// if fragment has been loaded to perform a bitrate test,
|
||
|
|
if (data.frag.bitrateTest) {
|
||
|
|
var stats = data.stats;
|
||
|
|
stats.tparsed = stats.tbuffered = stats.tload;
|
||
|
|
this.onFragBuffered(data);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}, {
|
||
|
|
key: 'onFragBuffered',
|
||
|
|
value: function onFragBuffered(data) {
|
||
|
|
var stats = data.stats,
|
||
|
|
frag = data.frag;
|
||
|
|
// only update stats on first frag buffering
|
||
|
|
// if same frag is loaded multiple times, it might be in browser cache, and loaded quickly
|
||
|
|
// and leading to wrong bw estimation
|
||
|
|
// on bitrate test, also only update stats once (if tload = tbuffered == on FRAG_LOADED)
|
||
|
|
if (stats.aborted !== true && frag.loadCounter === 1 && frag.type === 'main' && !isNaN(frag.sn) && (!frag.bitrateTest || stats.tload === stats.tbuffered)) {
|
||
|
|
// use tparsed-trequest instead of tbuffered-trequest to compute fragLoadingProcessing; rationale is that buffer appending only happens once media is attached
|
||
|
|
// in case we use config.startFragPrefetch while media is not attached yet, fragment might be parsed while media not attached yet, but it will only be buffered on media attached
|
||
|
|
// as a consequence it could happen really late in the process. meaning that appending duration might appears huge ... leading to underestimated throughput estimation
|
||
|
|
var fragLoadingProcessingMs = stats.tparsed - stats.trequest;
|
||
|
|
_logger.logger.log('latency/loading/parsing/append/kbps:' + Math.round(stats.tfirst - stats.trequest) + '/' + Math.round(stats.tload - stats.tfirst) + '/' + Math.round(stats.tparsed - stats.tload) + '/' + Math.round(stats.tbuffered - stats.tparsed) + '/' + Math.round(8 * stats.loaded / (stats.tbuffered - stats.trequest)));
|
||
|
|
this._bwEstimator.sample(fragLoadingProcessingMs, stats.loaded);
|
||
|
|
stats.bwEstimate = this._bwEstimator.getEstimate();
|
||
|
|
// if fragment has been loaded to perform a bitrate test, (hls.startLevel = -1), store bitrate test delay duration
|
||
|
|
if (frag.bitrateTest) {
|
||
|
|
this.bitrateTestDelay = fragLoadingProcessingMs / 1000;
|
||
|
|
} else {
|
||
|
|
this.bitrateTestDelay = 0;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}, {
|
||
|
|
key: 'onError',
|
||
|
|
value: function onError(data) {
|
||
|
|
// stop timer in case of frag loading error
|
||
|
|
switch (data.details) {
|
||
|
|
case _errors.ErrorDetails.FRAG_LOAD_ERROR:
|
||
|
|
case _errors.ErrorDetails.FRAG_LOAD_TIMEOUT:
|
||
|
|
this.clearTimer();
|
||
|
|
break;
|
||
|
|
default:
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}, {
|
||
|
|
key: 'clearTimer',
|
||
|
|
value: function clearTimer() {
|
||
|
|
clearInterval(this.timer);
|
||
|
|
this.timer = null;
|
||
|
|
}
|
||
|
|
|
||
|
|
// return next auto level
|
||
|
|
|
||
|
|
}, {
|
||
|
|
key: '_findBestLevel',
|
||
|
|
value: function _findBestLevel(currentLevel, currentFragDuration, currentBw, minAutoLevel, maxAutoLevel, maxFetchDuration, bwFactor, bwUpFactor, levels) {
|
||
|
|
for (var i = maxAutoLevel; i >= minAutoLevel; i--) {
|
||
|
|
var levelInfo = levels[i],
|
||
|
|
levelDetails = levelInfo.details,
|
||
|
|
avgDuration = levelDetails ? levelDetails.totalduration / levelDetails.fragments.length : currentFragDuration,
|
||
|
|
live = levelDetails ? levelDetails.live : false,
|
||
|
|
adjustedbw = void 0;
|
||
|
|
// follow algorithm captured from stagefright :
|
||
|
|
// https://android.googlesource.com/platform/frameworks/av/+/master/media/libstagefright/httplive/LiveSession.cpp
|
||
|
|
// Pick the highest bandwidth stream below or equal to estimated bandwidth.
|
||
|
|
// consider only 80% of the available bandwidth, but if we are switching up,
|
||
|
|
// be even more conservative (70%) to avoid overestimating and immediately
|
||
|
|
// switching back.
|
||
|
|
if (i <= currentLevel) {
|
||
|
|
adjustedbw = bwFactor * currentBw;
|
||
|
|
} else {
|
||
|
|
adjustedbw = bwUpFactor * currentBw;
|
||
|
|
}
|
||
|
|
var bitrate = levels[i].realBitrate ? Math.max(levels[i].realBitrate, levels[i].bitrate) : levels[i].bitrate,
|
||
|
|
fetchDuration = bitrate * avgDuration / adjustedbw;
|
||
|
|
|
||
|
|
_logger.logger.trace('level/adjustedbw/bitrate/avgDuration/maxFetchDuration/fetchDuration: ' + i + '/' + Math.round(adjustedbw) + '/' + bitrate + '/' + avgDuration + '/' + maxFetchDuration + '/' + fetchDuration);
|
||
|
|
// if adjusted bw is greater than level bitrate AND
|
||
|
|
if (adjustedbw > bitrate && (
|
||
|
|
// fragment fetchDuration unknown OR live stream OR fragment fetchDuration less than max allowed fetch duration, then this level matches
|
||
|
|
// we don't account for max Fetch Duration for live streams, this is to avoid switching down when near the edge of live sliding window ...
|
||
|
|
// special case to support startLevel = -1 (bitrateTest) on live streams : in that case we should not exit loop so that _findBestLevel will return -1
|
||
|
|
!fetchDuration || live && !this.bitrateTestDelay || fetchDuration < maxFetchDuration)) {
|
||
|
|
// as we are looping from highest to lowest, this will return the best achievable quality level
|
||
|
|
return i;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
// not enough time budget even with quality level 0 ... rebuffering might happen
|
||
|
|
return -1;
|
||
|
|
}
|
||
|
|
}, {
|
||
|
|
key: 'nextAutoLevel',
|
||
|
|
get: function get() {
|
||
|
|
var forcedAutoLevel = this._nextAutoLevel;
|
||
|
|
var bwEstimator = this._bwEstimator;
|
||
|
|
// in case next auto level has been forced, and bw not available or not reliable, return forced value
|
||
|
|
if (forcedAutoLevel !== -1 && (!bwEstimator || !bwEstimator.canEstimate())) {
|
||
|
|
return forcedAutoLevel;
|
||
|
|
}
|
||
|
|
// compute next level using ABR logic
|
||
|
|
var nextABRAutoLevel = this._nextABRAutoLevel;
|
||
|
|
// if forced auto level has been defined, use it to cap ABR computed quality level
|
||
|
|
if (forcedAutoLevel !== -1) {
|
||
|
|
nextABRAutoLevel = Math.min(forcedAutoLevel, nextABRAutoLevel);
|
||
|
|
}
|
||
|
|
return nextABRAutoLevel;
|
||
|
|
},
|
||
|
|
set: function set(nextLevel) {
|
||
|
|
this._nextAutoLevel = nextLevel;
|
||
|
|
}
|
||
|
|
}, {
|
||
|
|
key: '_nextABRAutoLevel',
|
||
|
|
get: function get() {
|
||
|
|
var hls = this.hls,
|
||
|
|
maxAutoLevel = hls.maxAutoLevel,
|
||
|
|
levels = hls.levels,
|
||
|
|
config = hls.config,
|
||
|
|
minAutoLevel = hls.minAutoLevel;
|
||
|
|
var v = hls.media,
|
||
|
|
currentLevel = this.lastLoadedFragLevel,
|
||
|
|
currentFragDuration = this.fragCurrent ? this.fragCurrent.duration : 0,
|
||
|
|
pos = v ? v.currentTime : 0,
|
||
|
|
|
||
|
|
// playbackRate is the absolute value of the playback rate; if v.playbackRate is 0, we use 1 to load as
|
||
|
|
// if we're playing back at the normal rate.
|
||
|
|
playbackRate = v && v.playbackRate !== 0 ? Math.abs(v.playbackRate) : 1.0,
|
||
|
|
avgbw = this._bwEstimator ? this._bwEstimator.getEstimate() : config.abrEwmaDefaultEstimate,
|
||
|
|
|
||
|
|
// bufferStarvationDelay is the wall-clock time left until the playback buffer is exhausted.
|
||
|
|
bufferStarvationDelay = (_bufferHelper2.default.bufferInfo(v, pos, config.maxBufferHole).end - pos) / playbackRate;
|
||
|
|
|
||
|
|
// First, look to see if we can find a level matching with our avg bandwidth AND that could also guarantee no rebuffering at all
|
||
|
|
var bestLevel = this._findBestLevel(currentLevel, currentFragDuration, avgbw, minAutoLevel, maxAutoLevel, bufferStarvationDelay, config.abrBandWidthFactor, config.abrBandWidthUpFactor, levels);
|
||
|
|
if (bestLevel >= 0) {
|
||
|
|
return bestLevel;
|
||
|
|
} else {
|
||
|
|
_logger.logger.trace('rebuffering expected to happen, lets try to find a quality level minimizing the rebuffering');
|
||
|
|
// not possible to get rid of rebuffering ... let's try to find level that will guarantee less than maxStarvationDelay of rebuffering
|
||
|
|
// if no matching level found, logic will return 0
|
||
|
|
var maxStarvationDelay = currentFragDuration ? Math.min(currentFragDuration, config.maxStarvationDelay) : config.maxStarvationDelay,
|
||
|
|
bwFactor = config.abrBandWidthFactor,
|
||
|
|
bwUpFactor = config.abrBandWidthUpFactor;
|
||
|
|
if (bufferStarvationDelay === 0) {
|
||
|
|
// in case buffer is empty, let's check if previous fragment was loaded to perform a bitrate test
|
||
|
|
var bitrateTestDelay = this.bitrateTestDelay;
|
||
|
|
if (bitrateTestDelay) {
|
||
|
|
// if it is the case, then we need to adjust our max starvation delay using maxLoadingDelay config value
|
||
|
|
// max video loading delay used in automatic start level selection :
|
||
|
|
// in that mode ABR controller will ensure that video loading time (ie the time to fetch the first fragment at lowest quality level +
|
||
|
|
// the time to fetch the fragment at the appropriate quality level is less than ```maxLoadingDelay``` )
|
||
|
|
// cap maxLoadingDelay and ensure it is not bigger 'than bitrate test' frag duration
|
||
|
|
var maxLoadingDelay = currentFragDuration ? Math.min(currentFragDuration, config.maxLoadingDelay) : config.maxLoadingDelay;
|
||
|
|
maxStarvationDelay = maxLoadingDelay - bitrateTestDelay;
|
||
|
|
_logger.logger.trace('bitrate test took ' + Math.round(1000 * bitrateTestDelay) + 'ms, set first fragment max fetchDuration to ' + Math.round(1000 * maxStarvationDelay) + ' ms');
|
||
|
|
// don't use conservative factor on bitrate test
|
||
|
|
bwFactor = bwUpFactor = 1;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
bestLevel = this._findBestLevel(currentLevel, currentFragDuration, avgbw, minAutoLevel, maxAutoLevel, bufferStarvationDelay + maxStarvationDelay, bwFactor, bwUpFactor, levels);
|
||
|
|
return Math.max(bestLevel, 0);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}]);
|
||
|
|
|
||
|
|
return AbrController;
|
||
|
|
}(_eventHandler2.default);
|
||
|
|
|
||
|
|
exports.default = AbrController;
|