diff --git a/README.md b/README.md index b5f3c754..cc686b3e 100644 --- a/README.md +++ b/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(); diff --git a/package.json b/package.json index f2e8589a..16a7b5bf 100644 --- a/package.json +++ b/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" } diff --git a/src/playlist-loader.js b/src/playlist-loader.js index 1bd7099b..1f7861a0 100644 --- a/src/playlist-loader.js +++ b/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; diff --git a/src/playlist.js b/src/playlist.js index d767df73..ab37210b 100644 --- a/src/playlist.js +++ b/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; diff --git a/src/rendition-mixin.js b/src/rendition-mixin.js index 665a3771..268670b9 100644 --- a/src/rendition-mixin.js +++ b/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)); }; }; diff --git a/src/videojs-contrib-hls.js b/src/videojs-contrib-hls.js index feb1db8c..e8fd9323 100644 --- a/src/videojs-contrib-hls.js +++ b/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(); } diff --git a/test/karma/common.js b/test/karma/common.js index cd5b5de0..c3165bfd 100644 --- a/test/karma/common.js +++ b/test/karma/common.js @@ -45,7 +45,7 @@ var DEFAULTS = { debug: true, transform: [ 'babelify', - 'browserify-shim' + ['browserify-shim', { global: true }] ], noParse: [ 'test/data/**', diff --git a/test/rendition-mixin.test.js b/test/rendition-mixin.test.js index b721d2ad..7268ac4f 100644 --- a/test/rendition-mixin.test.js +++ b/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_; diff --git a/test/videojs-contrib-hls.test.js b/test/videojs-contrib-hls.test.js index aff0e821..4ce34f16 100644 --- a/test/videojs-contrib-hls.test.js +++ b/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; } });