Browse Source

Representations and Quality Levels (#929)

* fix representations enabled function closure and seperate blacklisting from enabling

* populate quality level list in hls when available

* use uri for representation id

* update readme

* add isEnabled and isBlacklisted function for playlists

* filter blacklisted playlists from representations

* update unit tests
pull/958/head
Matthew Neil 9 years ago
committed by GitHub
parent
commit
423910ed67
  1. 19
      README.md
  2. 1
      package.json
  3. 8
      src/playlist-loader.js
  4. 26
      src/playlist.js
  5. 28
      src/rendition-mixin.js
  6. 75
      src/videojs-contrib-hls.js
  7. 2
      test/karma/common.js
  8. 63
      test/rendition-mixin.test.js
  9. 39
      test/videojs-contrib-hls.test.js

19
README.md

@ -318,7 +318,24 @@ adaptive streaming logic.
#### hls.representations
Type: `function`
To get all of the available representations, call the `representations()` method on `player.hls`. This will return a list of plain objects, each with `width`, `height`, `bandwidth`, and `id` properties, and an `enabled()` method.
It is recommended to include the [videojs-contrib-quality-levels](https://github.com/videojs/videojs-contrib-quality-levels) plugin to your page so that videojs-contrib-hls will automatically populate the QualityLevelList exposed on the player by the plugin. You can access this list by calling `player.qualityLevels()`. See the [videojs-contrib-quality-levels project page](https://github.com/videojs/videojs-contrib-quality-levels) for more information on how to use the api.
Example, only enabling representations with a width greater than or equal to 720:
```javascript
var qualityLevels = player.qualityLevels();
for (var i = 0; i < qualityLevels.length; i++) {
var quality = qualityLevels[i];
if (quality.width >= 720) {
quality.enabled = true;
} else {
quality.enabled = false;
}
}
```
If including [videojs-contrib-quality-levels](https://github.com/videojs/videojs-contrib-quality-levels) is not an option, you can use the representations api. To get all of the available representations, call the `representations()` method on `player.hls`. This will return a list of plain objects, each with `width`, `height`, `bandwidth`, and `id` properties, and an `enabled()` method.
```javascript
player.hls.representations();

1
package.json

@ -134,6 +134,7 @@
"sinon": "1.10.2",
"uglify-js": "^2.5.0",
"videojs-standard": "^4.0.3",
"videojs-contrib-quality-levels": "^2.0.2",
"watchify": "^3.6.0",
"webpack": "^1.13.2"
}

8
src/playlist-loader.js

@ -7,6 +7,7 @@
*/
import resolveUrl from './resolve-url';
import {mergeOptions} from 'video.js';
import { isEnabled } from './playlist.js';
import Stream from './stream';
import m3u8 from 'm3u8-parser';
import window from 'global/window';
@ -229,9 +230,7 @@ const PlaylistLoader = function(srcUrl, hls, withCredentials) {
* @return {Number} number of eneabled playlists
*/
loader.enabledPlaylists_ = function() {
return loader.master.playlists.filter((element, index, array) => {
return !element.excludeUntil || element.excludeUntil <= Date.now();
}).length;
return loader.master.playlists.filter(isEnabled).length;
};
/**
@ -249,8 +248,7 @@ const PlaylistLoader = function(srcUrl, hls, withCredentials) {
let currentBandwidth = loader.media().attributes.BANDWIDTH || 0;
return !(loader.master.playlists.filter((playlist) => {
let enabled = typeof playlist.excludeUntil === 'undefined' ||
playlist.excludeUntil <= Date.now();
const enabled = isEnabled(playlist);
if (!enabled) {
return false;

26
src/playlist.js

@ -454,9 +454,35 @@ export const getMediaInfoForTime_ = function(playlist, currentTime, startIndex,
};
};
/**
* Check whether the playlist is blacklisted or not.
*
* @param {Object} playlist the media playlist object
* @return {boolean} whether the playlist is blacklisted or not
* @function isBlacklisted
*/
export const isBlacklisted = function(playlist) {
return playlist.excludeUntil && playlist.excludeUntil > Date.now();
};
/**
* Check whether the playlist is enabled or not.
*
* @param {Object} playlist the media playlist object
* @return {boolean} whether the playlist is enabled or not
* @function isEnabled
*/
export const isEnabled = function(playlist) {
const blacklisted = isBlacklisted(playlist);
return (!playlist.disabled && !blacklisted);
};
Playlist.duration = duration;
Playlist.seekable = seekable;
Playlist.getMediaInfoForTime_ = getMediaInfoForTime_;
Playlist.isEnabled = isEnabled;
Playlist.isBlacklisted = isBlacklisted;
// exports
export default Playlist;

28
src/rendition-mixin.js

@ -1,3 +1,4 @@
import { isBlacklisted, isEnabled } from './playlist.js';
/**
* Enable/disable playlist function. It is intended to have the first two
* arguments partially-applied in order to create the final per-playlist
@ -11,21 +12,22 @@
* or if undefined returns the current enabled-state for the playlist
* @return {Boolean} The current enabled-state of the playlist
*/
let enableFunction = (playlist, changePlaylistFn, enable) => {
let currentlyEnabled = typeof playlist.excludeUntil === 'undefined' ||
playlist.excludeUntil <= Date.now();
const enableFunction = (loader, playlistUri, changePlaylistFn, enable) => {
const playlist = loader.master.playlists[playlistUri];
const blacklisted = isBlacklisted(playlist);
const currentlyEnabled = isEnabled(playlist);
if (typeof enable === 'undefined') {
return currentlyEnabled;
}
if (enable !== currentlyEnabled) {
if (enable) {
delete playlist.excludeUntil;
} else {
playlist.excludeUntil = Infinity;
}
if (enable) {
delete playlist.disabled;
} else {
playlist.disabled = true;
}
if (enable !== currentlyEnabled && !blacklisted) {
// Ensure the outside world knows about our changes
changePlaylistFn();
}
@ -69,7 +71,10 @@ class Representation {
// Partially-apply the enableFunction to create a playlist-
// specific variant
this.enabled = enableFunction.bind(this, playlist, fastChangeFunction);
this.enabled = enableFunction.bind(this,
hlsHandler.playlists,
playlist.uri,
fastChangeFunction);
}
}
@ -87,7 +92,8 @@ let renditionSelectionMixin = function(hlsHandler) {
return playlists
.master
.playlists
.map((e, i) => new Representation(hlsHandler, e, i));
.filter((media) => !isBlacklisted(media))
.map((e, i) => new Representation(hlsHandler, e, e.uri));
};
};

75
src/videojs-contrib-hls.js

@ -77,6 +77,45 @@ const safeGetComputedStyle = function(el, property) {
return result[property];
};
/**
* Updates the selectedIndex of the QualityLevelList when a mediachange happens in hls.
*
* @param {QualityLevelList} qualityLevels The QualityLevelList to update.
* @param {PlaylistLoader} playlistLoader PlaylistLoader containing the new media info.
* @function handleHlsMediaChange
*/
const handleHlsMediaChange = function(qualityLevels, playlistLoader) {
let newPlaylist = playlistLoader.media();
let selectedIndex = -1;
for (let i = 0; i < qualityLevels.length; i++) {
if (qualityLevels[i].id === newPlaylist.uri) {
selectedIndex = i;
break;
}
}
qualityLevels.selectedIndex_ = selectedIndex;
qualityLevels.trigger({
selectedIndex,
type: 'change'
});
};
/**
* Adds quality levels to list once playlist metadata is available
*
* @param {QualityLevelList} qualityLevels The QualityLevelList to attach events to.
* @param {Object} hls Hls object to listen to for media events.
* @function handleHlsLoadedMetadata
*/
const handleHlsLoadedMetadata = function(qualityLevels, hls) {
hls.representations().forEach((rep) => {
qualityLevels.addQualityLevel(rep);
});
handleHlsMediaChange(qualityLevels, hls.playlists);
};
/**
* Chooses the appropriate media playlist based on the current
* bandwidth estimate and the player size.
@ -88,7 +127,6 @@ Hls.STANDARD_PLAYLIST_SELECTOR = function() {
let effectiveBitrate;
let sortedPlaylists = this.playlists.master.playlists.slice();
let bandwidthPlaylists = [];
let now = +new Date();
let i;
let variant;
let bandwidthBestVariant;
@ -102,12 +140,7 @@ Hls.STANDARD_PLAYLIST_SELECTOR = function() {
// filter out any playlists that have been excluded due to
// incompatible configurations or playback errors
sortedPlaylists = sortedPlaylists.filter((localVariant) => {
if (typeof localVariant.excludeUntil !== 'undefined') {
return now >= localVariant.excludeUntil;
}
return true;
});
sortedPlaylists = sortedPlaylists.filter(Playlist.isEnabled);
// filter out any variant that has greater effective bitrate
// than the current estimated bandwidth
@ -377,6 +410,7 @@ class HlsHandler extends Component {
this.options_.url = this.source_.src;
this.options_.tech = this.tech_;
this.options_.externHls = Hls;
this.masterPlaylistController_ = new MasterPlaylistController(this.options_);
this.playbackWatcher_ = new PlaybackWatcher(
videojs.mergeOptions(this.options_, {
@ -513,6 +547,8 @@ class HlsHandler extends Component {
this.ignoreNextSeekingEvent_ = true;
});
this.setupQualityLevels_();
// do nothing if the tech has been disposed already
// this can occur if someone sets the src in player.ready(), for instance
if (!this.tech_.el()) {
@ -523,6 +559,28 @@ class HlsHandler extends Component {
this.masterPlaylistController_.mediaSource));
}
/**
* Initializes the quality levels and sets listeners to update them.
*
* @method setupQualityLevels_
* @private
*/
setupQualityLevels_() {
let player = videojs.players[this.tech_.options_.playerId];
if (player && player.qualityLevels) {
this.qualityLevels_ = player.qualityLevels();
this.masterPlaylistController_.on('selectedinitialmedia', () => {
handleHlsLoadedMetadata(this.qualityLevels_, this);
});
this.playlists.on('mediachange', () => {
handleHlsMediaChange(this.qualityLevels_, this.playlists);
});
}
}
/**
* a helper for grabbing the active audio group from MasterPlaylistController
*
@ -570,6 +628,9 @@ class HlsHandler extends Component {
if (this.masterPlaylistController_) {
this.masterPlaylistController_.dispose();
}
if (this.qualityLevels_) {
this.qualityLevels_.dispose();
}
this.tech_.audioTracks().removeEventListener('change', this.audioTrackChange_);
super.dispose();
}

2
test/karma/common.js

@ -45,7 +45,7 @@ var DEFAULTS = {
debug: true,
transform: [
'babelify',
'browserify-shim'
['browserify-shim', { global: true }]
],
noParse: [
'test/data/**',

63
test/rendition-mixin.test.js

@ -34,6 +34,14 @@ const makeMockPlaylist = function(options) {
playlist.excludeUntil = options.excludeUntil;
}
if ('uri' in options) {
playlist.uri = options.uri;
}
if ('disabled' in options) {
playlist.disabled = options.disabled;
}
return playlist;
};
@ -55,7 +63,13 @@ const makeMockHlsHandler = function(playlistOptions) {
}
};
hlsHandler.playlists.master.playlists = playlistOptions.map(makeMockPlaylist);
playlistOptions.forEach((playlist, i) => {
hlsHandler.playlists.master.playlists[i] = makeMockPlaylist(playlist);
if (playlist.uri) {
hlsHandler.playlists.master.playlists[playlist.uri] = hlsHandler.playlists.master.playlists[i];
}
});
return hlsHandler;
};
@ -135,15 +149,31 @@ QUnit.test('returns representations with width and height if present', function(
assert.equal(renditions[2].height, undefined, 'rendition has a height of undefined');
});
QUnit.test('representations are disabled if their excludeUntil is after Date.now', function(assert) {
QUnit.test('blacklisted playlists are not included in the representations list', function(assert) {
let hlsHandler = makeMockHlsHandler([
{
bandwidth: 0,
excludeUntil: Infinity
excludeUntil: Infinity,
uri: 'media0.m3u8'
},
{
bandwidth: 0,
excludeUntil: 0,
uri: 'media1.m3u8'
},
{
bandwidth: 0,
excludeUntil: Date.now() + 999999,
uri: 'media2.m3u8'
},
{
bandwidth: 0,
excludeUntil: 1,
uri: 'media3.m3u8'
},
{
bandwidth: 0,
excludeUntil: 0
uri: 'media4.m3u8'
}
]);
@ -151,19 +181,23 @@ QUnit.test('representations are disabled if their excludeUntil is after Date.now
let renditions = hlsHandler.representations();
assert.equal(renditions[0].enabled(), false, 'rendition is not enabled');
assert.equal(renditions[1].enabled(), true, 'rendition is enabled');
assert.equal(renditions.length, 3, 'blacklisted rendition not added');
assert.equal(renditions[0].id, 'media1.m3u8', 'rendition is enabled');
assert.equal(renditions[1].id, 'media3.m3u8', 'rendition is enabled');
assert.equal(renditions[2].id, 'media4.m3u8', 'rendition is enabled');
});
QUnit.test('setting a representation to disabled sets excludeUntil to Infinity', function(assert) {
QUnit.test('setting a representation to disabled sets disabled to true', function(assert) {
let hlsHandler = makeMockHlsHandler([
{
bandwidth: 0,
excludeUntil: 0
excludeUntil: 0,
uri: 'media0.m3u8'
},
{
bandwidth: 0,
excludeUntil: 0
excludeUntil: 0,
uri: 'media1.m3u8'
}
]);
let playlists = hlsHandler.playlists.master.playlists;
@ -174,19 +208,22 @@ QUnit.test('setting a representation to disabled sets excludeUntil to Infinity',
renditions[0].enabled(false);
assert.equal(playlists[0].excludeUntil, Infinity, 'rendition has an infinite excludeUntil');
assert.equal(playlists[1].excludeUntil, 0, 'rendition has an excludeUntil of zero');
assert.equal(playlists[0].disabled, true, 'rendition has been disabled');
assert.equal(playlists[1].disabled, undefined, 'rendition has not been disabled');
assert.equal(playlists[0].excludeUntil, 0, 'excludeUntil not touched when disabling a rendition');
assert.equal(playlists[1].excludeUntil, 0, 'excludeUntil not touched when disabling a rendition');
});
QUnit.test('changing the enabled state of a representation calls fastQualityChange_', function(assert) {
let hlsHandler = makeMockHlsHandler([
{
bandwidth: 0,
excludeUntil: Infinity
disabled: true,
uri: 'media0.m3u8'
},
{
bandwidth: 0,
excludeUntil: 0
uri: 'media1.m3u8'
}
]);
let mpc = hlsHandler.masterPlaylistController_;

39
test/videojs-contrib-hls.test.js

@ -18,9 +18,12 @@ import {
import {HlsSourceHandler, HlsHandler, Hls} from '../src/videojs-contrib-hls';
import HlsAudioTrack from '../src/hls-audio-track';
import window from 'global/window';
// we need this so the plugin registers itself
import 'videojs-contrib-quality-levels';
/* eslint-enable no-unused-vars */
const Flash = videojs.getComponent('Flash');
const ogHlsHandlerSetupQualityLevels = videojs.HlsHandler.prototype.setupQualityLevels_;
let nextId = 0;
// do a shallow copy of the properties of source onto the target object
@ -1455,6 +1458,7 @@ QUnit.test('the source handler supports HLS mime types', function(assert) {
});
QUnit.test('fires loadstart manually if Flash is used', function(assert) {
videojs.HlsHandler.prototype.setupQualityLevels_ = () => {};
let tech = new (videojs.getTech('Flash'))({});
let loadstarts = 0;
@ -1469,6 +1473,7 @@ QUnit.test('fires loadstart manually if Flash is used', function(assert) {
assert.equal(loadstarts, 0, 'loadstart is not synchronous');
this.clock.tick(1);
assert.equal(loadstarts, 1, 'fired loadstart');
videojs.HlsHandler.prototype.setupQualityLevels_ = ogHlsHandlerSetupQualityLevels;
});
QUnit.test('has no effect if native HLS is available', function(assert) {
@ -2314,6 +2319,36 @@ QUnit.test('passes useCueTags hls option to master playlist controller', functio
videojs.options.hls = origHlsOptions;
});
QUnit.test('populates quality levels list when available', function(assert) {
this.player.src({
src: 'manifest/master.m3u8',
type: 'application/vnd.apple.mpegurl'
});
assert.ok(this.player.tech_.hls.qualityLevels_, 'added quality levels');
let qualityLevels = this.player.qualityLevels();
let addCount = 0;
let changeCount = 0;
qualityLevels.on('addqualitylevel', () => {
addCount++;
});
qualityLevels.on('change', () => {
changeCount++;
});
openMediaSource(this.player, this.clock);
// master
standardXHRResponse(this.requests.shift());
// media
standardXHRResponse(this.requests.shift());
assert.equal(addCount, 4, 'four levels added from master');
assert.equal(changeCount, 1, 'selected initial quality level');
});
QUnit.module('HLS Integration', {
beforeEach(assert) {
this.env = useFakeEnvironment(assert);
@ -2321,10 +2356,12 @@ QUnit.module('HLS Integration', {
this.mse = useFakeMediaSource();
this.tech = new (videojs.getTech('Html5'))({});
this.clock = this.env.clock;
videojs.HlsHandler.prototype.setupQualityLevels_ = () => {};
},
afterEach() {
this.env.restore();
this.mse.restore();
videojs.HlsHandler.prototype.setupQualityLevels_ = ogHlsHandlerSetupQualityLevels;
}
});
@ -2585,10 +2622,12 @@ QUnit.module('HLS - Encryption', {
this.requests = this.env.requests;
this.mse = useFakeMediaSource();
this.tech = new (videojs.getTech('Html5'))({});
videojs.HlsHandler.prototype.setupQualityLevels_ = () => {};
},
afterEach() {
this.env.restore();
this.mse.restore();
videojs.HlsHandler.prototype.setupQualityLevels_ = ogHlsHandlerSetupQualityLevels;
}
});

Loading…
Cancel
Save