diff --git a/package.json b/package.json index 131d2d5e3..c2d717e40 100644 --- a/package.json +++ b/package.json @@ -97,6 +97,7 @@ "victory": "^0.25.6", "video.js": "^5.20.1", "videojs-contrib-hls": "^5.8.3", + "videojs-contrib-quality-levels": "^2.0.4", "videojs-resolution-switcher": "^0.4.2", "videojs-youtube": "^2.4.1", "webpack": "^3.2.0", diff --git a/static/js/components/VideoPlayer.js b/static/js/components/VideoPlayer.js index d634954fe..86f55ecd1 100644 --- a/static/js/components/VideoPlayer.js +++ b/static/js/components/VideoPlayer.js @@ -47,6 +47,7 @@ const makeConfigForVideo = ( ] : video.sources, plugins: { + hlsQualitySelector: {}, videoJsResolutionSwitcher: { default: "high", dynamicLabel: true @@ -294,17 +295,21 @@ class VideoPlayer extends React.Component<*, void> { if (this.player.tech_.currentTime() < 10) { return _.last(playlists) } - // Return playlist with highest bandwidth <= system bandwidth - return _.last( - R.filter( - rep => - rep.attributes.BANDWIDTH <= - _.max([ - this.player.tech_.hls.systemBandwidth, - playlists[0].attributes.BANDWIDTH - ]), - playlists - ) + // Return active playlist with highest bandwidth <= system bandwidth, + // or first active playlist otherwise. + const activePlaylists = R.filter((rep) => !rep.disabled, playlists) + return ( + _.last( + R.filter( + ((rep) => { + return rep.attributes.BANDWIDTH <= _.max([ + this.player.tech_.hls.systemBandwidth, + playlists[0].attributes.BANDWIDTH + ]) + }), + activePlaylists + ) + ) || activePlaylists[0] ) } @@ -314,7 +319,6 @@ class VideoPlayer extends React.Component<*, void> { const cropVideo = this.cropVideo const createEventHandler = this.createEventHandler const toggleFullscreen = this.toggleFullscreen - const selectPlaylist = this.selectPlaylist if (video.multiangle) { videojs.getComponent( "FullscreenToggle" @@ -322,6 +326,7 @@ class VideoPlayer extends React.Component<*, void> { } const useYouTube = video.is_public && video.youtube_id !== null this.lastMinuteTracked = null + const selectPlaylist = this.selectPlaylist.bind(this) this.player = videojs( this.videoNode, makeConfigForVideo(video, useYouTube, embed), diff --git a/static/js/components/VideoPlayer_test.js b/static/js/components/VideoPlayer_test.js index dbd1a0334..3a8c92b5a 100644 --- a/static/js/components/VideoPlayer_test.js +++ b/static/js/components/VideoPlayer_test.js @@ -109,6 +109,7 @@ describe("VideoPlayer", () => { }, playbackRates: [0.5, 0.75, 1.0, 1.25, 1.5, 2.0, 4.0], plugins: { + hlsQualitySelector: {}, videoJsResolutionSwitcher: { default: "high", dynamicLabel: true @@ -146,7 +147,7 @@ describe("VideoPlayer", () => { }) ;[false, true].forEach(function(embed) { it("video element is rendered with the correct style attributes", () => { - const wrapper = renderPlayer({embed}).find("VideoPlayer") + const wrapper = renderPlayer({ embed }).find("VideoPlayer") const videoProps = wrapper.find("video").props() assert.equal( videoProps.className, @@ -321,9 +322,44 @@ describe("VideoPlayer", () => { } }) }) - ;[5, 15].forEach(videoTime => { - [1000, 2000, 3000, 4000].forEach(bandwidth => { - it(`Returns correct playlist if elapsed time is ${videoTime} secs, bandwidth is ${bandwidth}`, () => { + + describe("selectPlaylist", () => { + describe("when elapsed time is < 10 seconds", () => { + [1000, 2000, 3000, 4000].forEach(bandwidth => { + it(`Returns correct playlist if bandwidth is ${bandwidth}`, () => { + const videoTime = 5 + playerStub.tech_ = { + currentTime: sandbox.stub().returns(videoTime), + hls: { + selectPlaylist: sandbox.stub(), + playlists: { + master: { + playlists: [ + { attributes: { BANDWIDTH: 900 } }, + { attributes: { BANDWIDTH: 1900 } }, + { attributes: { BANDWIDTH: 2900 } }, + { attributes: { BANDWIDTH: 3900 } } + ] + } + }, + systemBandwidth: bandwidth + } + } + const wrapper = renderPlayer().find("VideoPlayer") + wrapper.instance().player = playerStub + const bestPlayList = wrapper.instance().selectPlaylist() + assert.equal( + bestPlayList.attributes.BANDWIDTH, + videoTime < 10 ? 3900 : bandwidth - 100 + ) + }) + }) + }) + + describe("when elapsed time is > 10 secs", () => { + const videoTime = 11 + + it("selects highest active playlist <= system bandwidth", () => { playerStub.tech_ = { currentTime: sandbox.stub().returns(videoTime), hls: { @@ -332,25 +368,47 @@ describe("VideoPlayer", () => { master: { playlists: [ { attributes: { BANDWIDTH: 900 } }, - { attributes: { BANDWIDTH: 1900 } }, + { attributes: { BANDWIDTH: 1900 }, disabled: true }, { attributes: { BANDWIDTH: 2900 } }, { attributes: { BANDWIDTH: 3900 } } ] } }, - systemBandwidth: bandwidth + systemBandwidth: 2000, } } const wrapper = renderPlayer().find("VideoPlayer") wrapper.instance().player = playerStub const bestPlayList = wrapper.instance().selectPlaylist() - assert.equal( - bestPlayList.attributes.BANDWIDTH, - videoTime < 10 ? 3900 : bandwidth - 100 - ) + assert.equal(bestPlayList.attributes.BANDWIDTH, 900) + }) + + it("selects lowest playlist if no active playlist <= system bandwidth", () => { + playerStub.tech_ = { + currentTime: sandbox.stub().returns(videoTime), + hls: { + selectPlaylist: sandbox.stub(), + playlists: { + master: { + playlists: [ + { attributes: { BANDWIDTH: 900 }, disabled: true}, + { attributes: { BANDWIDTH: 1900 }, disabled: true }, + { attributes: { BANDWIDTH: 2900 } }, + { attributes: { BANDWIDTH: 3900 } } + ] + } + }, + systemBandwidth: 2000, + } + } + const wrapper = renderPlayer().find("VideoPlayer") + wrapper.instance().player = playerStub + const bestPlayList = wrapper.instance().selectPlaylist() + assert.equal(bestPlayList.attributes.BANDWIDTH, 2900) }) }) }) + ;[false, true].forEach(function(isPublic) { ["asdJ4y", null].forEach(function(youtubeId) { it(`checkYouTube ${expect( diff --git a/static/js/lib/video.js b/static/js/lib/video.js index 07b061933..2aaf0cc1c 100644 --- a/static/js/lib/video.js +++ b/static/js/lib/video.js @@ -16,12 +16,17 @@ import type { Video, VideoFile } from "../flow/videoTypes" import _videojs from "video.js" import { makeVideoFileName, makeVideoFileUrl } from "./urls" +import hlsQualitySelector from "./videojs_hls_quality_selector" + // For this to work properly videojs must be available as a global global.videojs = _videojs require("videojs-contrib-hls") +require("videojs-contrib-quality-levels") require("videojs-resolution-switcher") require("videojs-youtube") +_videojs.plugin("hlsQualitySelector", hlsQualitySelector) + // export here to allow mocking of videojs function export const videojs = _videojs diff --git a/static/js/lib/videojs_hls_quality_selector.js b/static/js/lib/videojs_hls_quality_selector.js new file mode 100644 index 000000000..77b1fecc0 --- /dev/null +++ b/static/js/lib/videojs_hls_quality_selector.js @@ -0,0 +1,135 @@ +/* Adapted from https://github.com/chrisboustead/videojs-hls-quality-selector . + * We use our own version rather than the original because + * the original structures its code in a way that makes it difficult to + * register the plugin; + * the original will register the plugin against its own videojs instance, + * from its own node_modules, rather than our videojs instance. + * This happens regardless of whether we 'require' or 'import' it. + * So, we use our own version instead. + */ + +import videojs from "video.js" + +const hlsQualitySelector = function() { + const player = this + player.ready(() => { + player.hlsQualitySelector = new HlsQualitySelectorPlugin(player) + }) +} + +class HlsQualitySelectorPlugin { + constructor(player) { + this.player = player + this.keyedMenuItems = {} + this.KEY_FOR_AUTO = "__AUTO__" + if (this.player.qualityLevels && this.getHls()) { + this.createQualityMenu() + this.bindPlayerEvents() + } + } + + getHls() { + return this.player.tech({ IWillNotUseThisInPlugins: true }).hls + } + + bindPlayerEvents() { + this.player + .qualityLevels() + .on("addqualitylevel", this.onAddQualityLevel.bind(this)) + } + + createQualityMenu() { + const player = this.player + const videoJsButtonClass = videojs.getComponent("MenuButton") + const concreteButtonClass = videojs.extend(videoJsButtonClass, { + constructor: function() { + videoJsButtonClass.call(this, player, { + title: player.localize("Quality") + }) + }, + createItems: function() { + return [] + } + }) + + this._qualityMenuButton = new concreteButtonClass() + + const placementIndex = player.controlBar.children().length - 2 + const concreteButtonInstance = player.controlBar.addChild( + this._qualityMenuButton, + { componentClass: "qualitySelector" }, + placementIndex + ) + concreteButtonInstance.addClass("vjs-quality-selector") + concreteButtonInstance.addClass("vjs-icon-hd") + concreteButtonInstance.removeClass("vjs-hidden") + } + + createQualityMenuItem(item) { + const player = this.player + const videoJsMenuItemClass = videojs.getComponent("MenuItem") + const concreteMenuItemClass = videojs.extend(videoJsMenuItemClass, { + constructor: function() { + videoJsMenuItemClass.call(this, player, { + label: item.label, + selectable: true, + selected: item.selected || false + }) + this.key = item.key + }, + handleClick: () => { + this.selectQualityLevel({ key: item.key }) + this._qualityMenuButton.unpressButton() + } + }) + return new concreteMenuItemClass() + } + + onAddQualityLevel() { + const player = this.player + const qualityList = player.qualityLevels() + const levels = qualityList.levels_ || [] + + const menuItems = [] + for (let i = 0; i < levels.length; ++i) { + const menuItem = this.createQualityMenuItem.call(this, { + key: levels[i].id, + label: `${levels[i].height}p`, + value: levels[i].height + }) + menuItems.push(menuItem) + } + menuItems.push( + this.createQualityMenuItem.call(this, { + key: this.KEY_FOR_AUTO, + label: "Auto", + value: "auto", + selected: true + }) + ) + + if (this._qualityMenuButton) { + this._qualityMenuButton.createItems = () => menuItems + this._qualityMenuButton.update() + } + + this.keyedMenuItems = {} + for (const menuItem of menuItems) { + this.keyedMenuItems[menuItem.key] = menuItem + } + } + + selectQualityLevel({ key }) { + for (let i = 0; i < this._qualityMenuButton.items.length; ++i) { + this._qualityMenuButton.items[i].selected(false) + } + const qualityList = this.player.qualityLevels() + for (let i = 0; i < qualityList.length; ++i) { + const quality = qualityList[i] + quality.enabled = quality.id === key || key === this.KEY_FOR_AUTO + } + this.keyedMenuItems[key].selected(true) + } +} + +export default hlsQualitySelector diff --git a/webpack.config.shared.js b/webpack.config.shared.js index a2787ebe6..7abeb9bd0 100644 --- a/webpack.config.shared.js +++ b/webpack.config.shared.js @@ -29,6 +29,7 @@ module.exports = { extensions: ['.js', '.jsx'], alias: { 'videojs-contrib-hls': path.resolve(__dirname, 'node_modules/videojs-contrib-hls/dist/videojs-contrib-hls.js'), + 'videojs-contrib-quality-levels': path.resolve(__dirname, 'node_modules/videojs-contrib-quality-levels/dist/videojs-contrib-quality-levels.js'), } }, performance: { diff --git a/yarn.lock b/yarn.lock index 9c99ec8a0..b1a64e040 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1619,6 +1619,13 @@ browserify-sign@^4.0.0: inherits "^2.0.1" parse-asn1 "^5.0.0" +browserify-versionify@^1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/browserify-versionify/-/browserify-versionify-1.0.6.tgz#ab2dc61d6a119e627bec487598d1983b7fdb275e" + dependencies: + find-root "^0.1.1" + through2 "0.6.3" + browserify-zlib@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/browserify-zlib/-/browserify-zlib-0.1.4.tgz#bb35f8a519f600e0fa6b8485241c979d0141fb2d" @@ -3176,6 +3183,10 @@ find-cache-dir@^1.0.0: make-dir "^1.0.0" pkg-dir "^2.0.0" +find-root@^0.1.1: + version "0.1.2" + resolved "https://registry.yarnpkg.com/find-root/-/find-root-0.1.2.tgz#98d2267cff1916ccaf2743b3a0eea81d79d7dcd1" + find-up@^1.0.0: version "1.1.2" resolved "https://registry.yarnpkg.com/find-up/-/find-up-1.1.2.tgz#6b2e9822b1a2ce0a60ab64d610eccad53cb24d0f" @@ -3453,7 +3464,7 @@ global@4.3.0: min-document "^2.6.1" process "~0.5.1" -global@^4.3.0, global@^4.3.1, global@^4.3.2, global@~4.3.0: +global@4.3.2, global@^4.3.0, global@^4.3.1, global@^4.3.2, global@~4.3.0: version "4.3.2" resolved "https://registry.yarnpkg.com/global/-/global-4.3.2.tgz#e76989268a6c74c38908b1305b10fc0e394e9d0f" dependencies: @@ -5970,6 +5981,15 @@ read-pkg@^2.0.0: normalize-package-data "^2.3.2" path-type "^2.0.0" +"readable-stream@>=1.0.33-1 <1.1.0-0": + version "1.0.34" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.0.34.tgz#125820e34bc842d2f2aaafafe4c2916ee32c157c" + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.1" + isarray "0.0.1" + string_decoder "~0.10.x" + readable-stream@^2.0.1, readable-stream@^2.0.5, readable-stream@^2.0.6, readable-stream@^2.1.4, readable-stream@^2.2.2, readable-stream@^2.2.6: version "2.3.3" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.3.tgz#368f2512d79f9d46fdfc71349ae7878bbc1eb95c" @@ -6730,7 +6750,7 @@ string-width@^2.0.0, string-width@^2.1.0: is-fullwidth-code-point "^2.0.0" strip-ansi "^4.0.0" -string_decoder@^0.10.25: +string_decoder@^0.10.25, string_decoder@~0.10.x: version "0.10.31" resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94" @@ -6912,6 +6932,13 @@ text-table@~0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" +through2@0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/through2/-/through2-0.6.3.tgz#795292fde9f254c2a368b38f9cc5d1bd4663afb6" + dependencies: + readable-stream ">=1.0.33-1 <1.1.0-0" + xtend ">=4.0.0 <4.1.0-0" + through@^2.3.6: version "2.3.8" resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" @@ -7206,6 +7233,19 @@ victory@^0.25.6: victory-core "^21.0.4" victory-pie "^14.0.1" +"video.js@^5.10.1 || ^6.2.0": + version "6.7.3" + resolved "https://registry.yarnpkg.com/video.js/-/video.js-6.7.3.tgz#616ab015a74bb1bc8b092e9b4b8022519756f7c0" + dependencies: + babel-runtime "^6.9.2" + global "4.3.2" + safe-json-parse "4.0.0" + tsml "1.0.1" + videojs-font "2.1.0" + videojs-ie8 "1.1.2" + videojs-vtt.js "0.12.5" + xhr "2.4.0" + video.js@^5.17.0, "video.js@^5.19.1 || ^6.2.0", video.js@^5.20.1, "video.js@^5.6.0 || ^6.2.8": version "5.20.2" resolved "https://registry.yarnpkg.com/video.js/-/video.js-5.20.2.tgz#9f0b6121a2f841fc4b1c473d8983ef4d10c663ba" @@ -7242,10 +7282,22 @@ videojs-contrib-media-sources@4.4.8: video.js "^5.17.0" webworkify "1.0.2" +videojs-contrib-quality-levels@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/videojs-contrib-quality-levels/-/videojs-contrib-quality-levels-2.0.4.tgz#7d124bb5fb0cb0517c48eae1465af8f5b5db0f24" + dependencies: + browserify-versionify "^1.0.6" + global "^4.3.1" + video.js "^5.10.1 || ^6.2.0" + videojs-font@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/videojs-font/-/videojs-font-2.0.0.tgz#af7461ef9d4b95e0334bffb78b2f2ff0364a9034" +videojs-font@2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/videojs-font/-/videojs-font-2.1.0.tgz#a25930a67f6c9cfbf2bb88dacb8c6b451f093379" + videojs-ie8@1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/videojs-ie8/-/videojs-ie8-1.1.2.tgz#a23d3d8608ad7192b69c6077fc4eb848998d35d9" @@ -7266,6 +7318,12 @@ videojs-vtt.js@0.12.4: dependencies: global "^4.3.1" +videojs-vtt.js@0.12.5: + version "0.12.5" + resolved "https://registry.yarnpkg.com/videojs-vtt.js/-/videojs-vtt.js-0.12.5.tgz#32852732741c8b4e7a4314caa2cd93646a9c2d40" + dependencies: + global "^4.3.1" + videojs-youtube@^2.4.1: version "2.4.1" resolved "https://registry.yarnpkg.com/videojs-youtube/-/videojs-youtube-2.4.1.tgz#4ebd6736a09c636f79fcc324befd9db230780bfc" @@ -7476,11 +7534,20 @@ xhr@2.2.2: parse-headers "^2.0.0" xtend "^4.0.0" +xhr@2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/xhr/-/xhr-2.4.0.tgz#e16e66a45f869861eeefab416d5eff722dc40993" + dependencies: + global "~4.3.0" + is-function "^1.0.1" + parse-headers "^2.0.0" + xtend "^4.0.0" + xml-name-validator@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a" -xtend@^4.0.0: +"xtend@>=4.0.0 <4.1.0-0", xtend@^4.0.0: version "4.0.1" resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.1.tgz#a5c6d532be656e23db820efb943a1f04998d63af"