diff --git a/demo/main.js b/demo/main.js index b4877acb32f..c17fedd686a 100644 --- a/demo/main.js +++ b/demo/main.js @@ -313,7 +313,8 @@ function loadSelectedStream() { {this.sumLevelParsingMs = parsingDuration;} stats.levelParsed++; - stats.levelParsingUs = Math.round(1000*this.sumLevelParsingMs / stats.levelParsed); + stats.levelParsingUs = Math.round(1000 * this.sumLevelParsingMs / stats.levelParsed); + stats.levelsSuppressed = hls.levelSuppression._levelsSuppressed; console.log('parsing level duration :' + stats.levelParsingUs + 'us,count:' + stats.levelParsed); events.load.push(event); refreshCanvas(); @@ -934,11 +935,12 @@ function updateLevelInfo() { } let button_template = ''; html2 += button_template; - if(hls.loadLevel === i) { + if (hls.loadLevel === i) { html2 += button_enabled; + } else if (hls.levelSuppression._levelsSuppressed[i]) { + html2 += button_suppressed; } else { html2 += button_disabled; } @@ -998,7 +1005,7 @@ function updateLevelInfo() { html2 += 'onclick="hls.loadLevel=' + i + '">' + levelName + ''; html3 += button_template; - if(hls.autoLevelCapping === i) { + if (hls.autoLevelCapping === i) { html3 += button_enabled; } else { html3 += button_disabled; @@ -1007,8 +1014,10 @@ function updateLevelInfo() { html3 += 'onclick="levelCapping=hls.autoLevelCapping=' + i + ';updateLevelInfo();updatePermalink();">' + levelName + ''; html4 += button_template; - if(hls.nextLevel === i) { + if (hls.nextLevel === i) { html4 += button_enabled; + } else if (hls.levelSuppression._levelsSuppressed[i]) { + html4 += button_suppressed; } else { html4 += button_disabled; } diff --git a/doc/API.md b/doc/API.md index bc5ed7acbcd..061c287a280 100644 --- a/doc/API.md +++ b/doc/API.md @@ -42,6 +42,7 @@ - [`liveDurationInfinity`](#livedurationinfinity) - [`enableWorker`](#enableworker) - [`enableSoftwareAES`](#enablesoftwareaes) + - [`enableLevelSuppression`](#enableLevelSuppression) - [`startLevel`](#startlevel) - [`fragLoadingTimeOut` / `manifestLoadingTimeOut` / `levelLoadingTimeOut`](#fragloadingtimeout--manifestloadingtimeout--levelloadingtimeout) - [`fragLoadingMaxRetry` / `manifestLoadingMaxRetry` / `levelLoadingMaxRetry`](#fragloadingmaxretry--manifestloadingmaxretry--levelloadingmaxretry) @@ -310,6 +311,7 @@ Configuration parameters could be provided to hls.js upon instantiation of `Hls` liveMaxLatencyDurationCount: 10, enableWorker: true, enableSoftwareAES: true, + enableLevelSuppression: false, manifestLoadingTimeOut: 10000, manifestLoadingMaxRetry: 1, manifestLoadingRetryDelay: 500, @@ -552,6 +554,12 @@ Enable WebWorker (if available on browser) for TS demuxing/MP4 remuxing, to impr Enable to use JavaScript version AES decryption for fallback of WebCrypto API. +### `enableLevelSuppression` + +(default: `false`) + +Enable to use Level Suppression. When enabled, levels will be suppressed for a given timeout (config.levelLoadingMaxRetryTimeout) when attempting to switch. + ### `startLevel` (default: `undefined`) diff --git a/src/config.js b/src/config.js index 075074dd35e..14019ecab23 100644 --- a/src/config.js +++ b/src/config.js @@ -45,6 +45,7 @@ export var hlsDefaultConfig = { maxMaxBufferLength: 600, // used by stream-controller enableWorker: true, // used by demuxer enableSoftwareAES: true, // used by decrypter + enableLevelSuppression: false, // used by hls, level-controller manifestLoadingTimeOut: 10000, // used by playlist-loader manifestLoadingMaxRetry: 1, // used by playlist-loader manifestLoadingRetryDelay: 1000, // used by playlist-loader diff --git a/src/controller/level-controller.js b/src/controller/level-controller.js index ad5d32bad86..945293239f3 100644 --- a/src/controller/level-controller.js +++ b/src/controller/level-controller.js @@ -328,6 +328,11 @@ export default class LevelController extends EventHandler { } else { // Search for available level if (this.manualLevelIndex === -1) { + //mark problematic level as suppressed + if (this.hls.config.enableLevelSuppression) { + this.hls.levelSuppression.set(levelIndex, this.hls.config.levelLoadingMaxRetryTimeout); + logger.warn(`level controller, ${levelIndex} has been suppressed for ${this.hls.config.levelLoadingMaxRetryTimeout}`); + } // When lowest level has been reached, let's start hunt from the top nextLevel = (levelIndex === 0) ? this._levels.length - 1 : levelIndex - 1; logger.warn(`level controller, ${errorDetails}: switch to ${nextLevel}`); diff --git a/src/hls.js b/src/hls.js index 5000d36302b..273590b241c 100644 --- a/src/hls.js +++ b/src/hls.js @@ -15,8 +15,9 @@ import ID3TrackController from './controller/id3-track-controller'; import {isSupported} from './helper/is-supported'; import {logger, enableLogs} from './utils/logger'; import EventEmitter from 'events'; -import {hlsDefaultConfig} from './config'; -import {FragmentTracker} from './helper/fragment-tracker'; +import { hlsDefaultConfig } from './config'; +import { FragmentTracker } from './helper/fragment-tracker'; +import LevelSuppression from './utils/level-suppression'; // polyfill for IE11 require('string.prototype.endswith'); @@ -76,6 +77,7 @@ export default class Hls { enableLogs(config.debug); this.config = config; this._autoLevelCapping = -1; + this.levelSuppression = new LevelSuppression(); // observer setup var observer = this.observer = new EventEmitter(); observer.trigger = function trigger (event, ...data) { @@ -154,6 +156,7 @@ export default class Hls { this.url = null; this.observer.removeAllListeners(); this._autoLevelCapping = -1; + this.levelSuppression = null; } attachMedia(media) { @@ -334,7 +337,14 @@ export default class Hls { get nextAutoLevel() { const hls = this; // ensure next auto level is between min and max auto level - return Math.min(Math.max(hls.abrController.nextAutoLevel,hls.minAutoLevel),hls.maxAutoLevel); + let level = Math.min(Math.max(hls.abrController.nextAutoLevel, hls.minAutoLevel), hls.maxAutoLevel); + + if (hls.config.enableLevelSuppression && (hls.currentLevel !== level)) { + return this.getAppropriateLevel(level); + } else { + return level; + } + } // this setter is used to force next auto level @@ -342,7 +352,36 @@ export default class Hls { // forced value is valid for one fragment. upon succesful frag loading at forced level, this value will be resetted to -1 by ABR controller set nextAutoLevel(nextLevel) { const hls = this; - hls.abrController.nextAutoLevel = Math.max(hls.minAutoLevel,nextLevel); + + let level = Math.max(hls.minAutoLevel, nextLevel); + + if (hls.config.enableLevelSuppression) { + hls.abrController.nextAutoLevel = this.getAppropriateLevel(level); + } else { + hls.abrController.nextAutoLevel = level; + } + } + + getAppropriateLevel(level) { + + //to avoid extra checks, return level if not suppressed + if (!this.levelSuppression.isSuppressed(level)) return level; + + //reset if all levels have been suppressed, continute to suppress last problematic level + if (this.levelSuppression.isAllSuppressed(this.minAutoLevel, this.maxAutoLevel)) { + //reset + this.levelSuppression = new LevelSuppression(); + //suppress problematic level + this.levelSuppression.set(this.streamController.levelLastLoaded, this.config.levelLoadingMaxRetryTimeout); + } + + + //find non-suppressed level + while (this.levelSuppression.isSuppressed(level)) { + level = (level === 0) ? this.levels.length - 1 : level - 1; + } + + return level; } /** get alternate audio tracks list from playlist **/ diff --git a/src/utils/level-suppression.js b/src/utils/level-suppression.js new file mode 100644 index 00000000000..9318b43cdb5 --- /dev/null +++ b/src/utils/level-suppression.js @@ -0,0 +1,36 @@ +class LevelSuppression { + constructor() { + this._levelsSuppressed = {}; + } + + isSuppressed(level) { + + let expiration = this._levelsSuppressed[level]; + + if (Date.now() < expiration) { + return true; + } + + if (this._levelsSuppressed[level]) { + delete this._levelsSuppressed[level]; + } + + return false; + } + + isAllSuppressed(min, max) { + for (var i = min; i <= max; i++) { + if (!this.isSuppressed(i)) { + return false; + } + } + return true; + } + + set(level, ttl) { + this._levelsSuppressed[level] = ttl + Date.now(); + } + +} + +export default LevelSuppression; diff --git a/tests/unit/utils/level-suppression.js b/tests/unit/utils/level-suppression.js new file mode 100644 index 00000000000..d70101eb487 --- /dev/null +++ b/tests/unit/utils/level-suppression.js @@ -0,0 +1,125 @@ +/* + * + * + * + */ + +const assert = require('assert'); +const sinon = require('sinon'); +import LevelSuppression from '../../../src/utils/level-suppression'; +import Hls from '../../../src/hls'; + +describe('Level suppression logic', function () { + + + let levelSuppressionTimeout = 10000; + + + describe('level-suppression', function () { + + let levelSuppression; + let clock; + + beforeEach(function () { + clock = sinon.useFakeTimers(); + }); + + afterEach(function () { + clock.restore(); + }); + + it('suppresses some levels', function () { + levelSuppression = new LevelSuppression(); + levelSuppression.set(2, levelSuppressionTimeout); + levelSuppression.set(3, levelSuppressionTimeout); + levelSuppression.set(4, levelSuppressionTimeout); + + assert.strictEqual(levelSuppression.isSuppressed(0), false); + assert.strictEqual(levelSuppression.isSuppressed(1), false); + assert.strictEqual(levelSuppression.isSuppressed(2), true); + assert.strictEqual(levelSuppression.isSuppressed(3), true); + assert.strictEqual(levelSuppression.isSuppressed(4), true); + }); + + it('suppresses all levels', function () { + let levels = [0, 1, 2, 3, 4]; + levelSuppression = new LevelSuppression(); + + levels.forEach((level) => { + levelSuppression.set(level, levelSuppressionTimeout) + }); + + assert.equal(levelSuppression.isAllSuppressed(0, levels.length - 1), true); + }); + + it('is not suppressed when timeout is exceeded', function () { + + levelSuppression = new LevelSuppression(); + + levelSuppression.set(2, levelSuppressionTimeout); + levelSuppression.set(3, levelSuppressionTimeout); + levelSuppression.set(4, levelSuppressionTimeout); + + clock.tick(levelSuppressionTimeout); //advance clock by length of timeout + + assert.strictEqual(levelSuppression.isSuppressed(2), false); + assert.strictEqual(levelSuppression.isSuppressed(3), false); + assert.strictEqual(levelSuppression.isSuppressed(4), false); + }); + }); + + describe('getAppropriateLevel', function () { + let hls; + + beforeEach(function () { + hls = new Hls(); + hls.levelController._levels = [ + { bitrate: 105000, name: "144", details: { totalduration: 4, fragments: [{}] } }, + { bitrate: 246440, name: "240", details: { totalduration: 10, fragments: [{}] } }, + { bitrate: 460560, name: "380", details: { totalduration: 10, fragments: [{}] } }, + { bitrate: 836280, name: "480", details: { totalduration: 10, fragments: [{}] } }, + { bitrate: 2149280, name: "720", details: { totalduration: 10, fragments: [{}] } }, + { bitrate: 6221600, name: "1080", details: { totalduration: 10, fragments: [{}] } } + ]; + }); + + afterEach(function () { + hls = null; + }); + + it('returns the next non-suppressed level when one level is suppressed', function () { + + let currentLevel = 4; + + // suppress the fourth level + hls.levelSuppression.set(currentLevel, hls.config.levelLoadingMaxRetryTimeout); + + assert.strictEqual(hls.getAppropriateLevel(currentLevel), 3); + }); + + it('returns the next non-suppressed level when multiple levels are suppressed', function () { + + hls.levelSuppression.set(4, hls.config.levelLoadingMaxRetryTimeout); + hls.levelSuppression.set(5, hls.config.levelLoadingMaxRetryTimeout); + + let nonSuppressedLevel = hls.getAppropriateLevel(5); + + assert.strictEqual(nonSuppressedLevel, 3); + }); + + it('returns the last level if all levels are suppressed', function () { + + //last level hls loaded + hls.streamController.levelLastLoaded = 0; + + //suppress all levels + hls.levelController._levels.forEach((level, levelIndex) => { + hls.levelSuppression.set(levelIndex, hls.config.levelLoadingMaxRetryTimeout) + }); + + assert.strictEqual(hls.getAppropriateLevel(hls.abrController.nextAutoLevel), hls.levelController._levels.length - 1); + }); + + }) + +});