diff --git a/ui/component/viewers/videoViewer/internal/plugins/videojs-hls-quality-selector/ConcreteButton.js b/ui/component/viewers/videoViewer/internal/plugins/videojs-hls-quality-selector/ConcreteButton.js new file mode 100644 index 000000000..acff46ffb --- /dev/null +++ b/ui/component/viewers/videoViewer/internal/plugins/videojs-hls-quality-selector/ConcreteButton.js @@ -0,0 +1,85 @@ +import videojs from 'video.js'; + +const VideoJsButtonClass = videojs.getComponent('MenuButton'); +const VideoJsMenuClass = videojs.getComponent('Menu'); +const VideoJsComponent = videojs.getComponent('Component'); +const Dom = videojs.dom; + +/** + * Convert string to title case. + * + * @param {string} string - the string to convert + * @return {string} the returned titlecase string + */ +function toTitleCase(string) { + if (typeof string !== 'string') { + return string; + } + + return string.charAt(0).toUpperCase() + string.slice(1); +} + +/** + * Extend vjs button class for quality button. + */ +export default class ConcreteButton extends VideoJsButtonClass { + + /** + * Button constructor. + * + * @param {Player} player - videojs player instance + */ + constructor(player) { + super(player, { + title: player.localize('Quality'), + name: 'QualityButton' + }); + } + + /** + * Creates button items. + * + * @return {Array} - Button items + */ + createItems() { + return []; + } + + /** + * Create the menu and add all items to it. + * + * @return {Menu} + * The constructed menu + */ + createMenu() { + const menu = new VideoJsMenuClass(this.player_, {menuButton: this}); + + this.hideThreshold_ = 0; + + // Add a title list item to the top + if (this.options_.title) { + const titleEl = Dom.createEl('li', { + className: 'vjs-menu-title', + innerHTML: toTitleCase(this.options_.title), + tabIndex: -1 + }); + const titleComponent = new VideoJsComponent(this.player_, {el: titleEl}); + + this.hideThreshold_ += 1; + + menu.addItem(titleComponent); + } + + this.items = this.createItems(); + + if (this.items) { + // Add menu items to the menu + for (let i = 0; i < this.items.length; i++) { + menu.addItem(this.items[i]); + } + } + + return menu; + + } +} diff --git a/ui/component/viewers/videoViewer/internal/plugins/videojs-hls-quality-selector/ConcreteMenuItem.js b/ui/component/viewers/videoViewer/internal/plugins/videojs-hls-quality-selector/ConcreteMenuItem.js new file mode 100644 index 000000000..d1f90ad30 --- /dev/null +++ b/ui/component/viewers/videoViewer/internal/plugins/videojs-hls-quality-selector/ConcreteMenuItem.js @@ -0,0 +1,45 @@ +import videojs from 'video.js'; + +// Concrete classes +const VideoJsMenuItemClass = videojs.getComponent('MenuItem'); + +/** + * Extend vjs menu item class. + */ +export default class ConcreteMenuItem extends VideoJsMenuItemClass { + + /** + * Menu item constructor. + * + * @param {Player} player - vjs player + * @param {Object} item - Item object + * @param {ConcreteButton} qualityButton - The containing button. + * @param {HlsQualitySelectorPlugin} plugin - This plugin instance. + */ + constructor(player, item, qualityButton, plugin) { + super(player, { + label: item.label, + selectable: true, + selected: item.selected || false + }); + this.item = item; + this.qualityButton = qualityButton; + this.plugin = plugin; + } + + /** + * Click event for menu item. + */ + handleClick() { + + // Reset other menu items selected status. + for (let i = 0; i < this.qualityButton.items.length; ++i) { + this.qualityButton.items[i].selected(false); + } + + // Set this menu item to selected, and set quality. + this.plugin.setQuality(this.item.value); + this.selected(true); + + } +} diff --git a/ui/component/viewers/videoViewer/internal/plugins/videojs-hls-quality-selector/package.json b/ui/component/viewers/videoViewer/internal/plugins/videojs-hls-quality-selector/package.json new file mode 100644 index 000000000..ec66b9117 --- /dev/null +++ b/ui/component/viewers/videoViewer/internal/plugins/videojs-hls-quality-selector/package.json @@ -0,0 +1,109 @@ +{ + "name": "videojs-hls-quality-selector", + "version": "1.1.4", + "description": "Adds a quality selector menu for HLS sources played in videojs. Requires `videojs-contrib-hls` and videojs-contrib-quality-levels plugins.", + "main": "dist/videojs-hls-quality-selector.cjs.js", + "module": "dist/videojs-hls-quality-selector.es.js", + "generator-videojs-plugin": { + "version": "5.0.3" + }, + "repository": "https://github.com/chrisboustead/videojs-hls-quality-selector.git", + "scripts": { + "prebuild": "npm run clean", + "build": "npm-run-all -p build:*", + "build:css": "npm-run-all build:css:sass build:css:bannerize", + "build:css:bannerize": "bannerize dist/videojs-hls-quality-selector.css --banner=scripts/banner.ejs", + "build:css:sass": "node-sass src/plugin.scss dist/videojs-hls-quality-selector.css --output-style=compressed --linefeed=lf", + "build:js": "npm-run-all build:js:rollup-modules build:js:rollup-umd build:js:bannerize build:js:uglify", + "build:js:bannerize": "bannerize dist/videojs-hls-quality-selector.js --banner=scripts/banner.ejs", + "build:js:rollup-modules": "rollup -c scripts/modules.rollup.config.js", + "build:js:rollup-umd": "rollup -c scripts/umd.rollup.config.js", + "build:js:uglify": "uglifyjs dist/videojs-hls-quality-selector.js --comments --mangle --compress -o dist/videojs-hls-quality-selector.min.js", + "build:test": "rollup -c scripts/test.rollup.config.js", + "clean": "rimraf dist test/dist", + "postclean": "mkdirp dist test/dist", + "docs": "npm-run-all docs:*", + "docs:api": "jsdoc src -r -c jsdoc.json -d docs/api", + "docs:toc": "doctoc README.md", + "lint": "vjsstandard", + "start": "npm-run-all -p start:server watch", + "start:server": "static -a 0.0.0.0 -p 9999 -H '{\"Cache-Control\": \"no-cache, must-revalidate\"}' .", + "pretest": "npm-run-all lint build", + "test": "karma start test/karma.conf.js", + "preversion": "npm test", + "version": "node scripts/version.js", + "watch": "npm-run-all -p watch:*", + "watch:css": "npm-run-all build:css:sass watch:css:sass", + "watch:css:sass": "node-sass src/plugin.scss dist/videojs-hls-quality-selector.css --output-style=compressed --linefeed=lf --watch src/**/*.scss", + "watch:js-modules": "rollup -c scripts/modules.rollup.config.js -w", + "watch:js-umd": "rollup -c scripts/umd.rollup.config.js -w", + "watch:test": "rollup -c scripts/test.rollup.config.js -w", + "prepublish": "npm run build", + "prepush": "npm run lint", + "precommit": "npm run docs:toc && git add README.md" + }, + "keywords": [ + "videojs", + "videojs-plugin" + ], + "author": "Chris Boustead (chris@forgemotion.com)", + "license": "MIT", + "vjsstandard": { + "ignore": [ + "dist", + "docs", + "test/dist", + "test/karma.conf.js" + ] + }, + "files": [ + "CONTRIBUTING.md", + "dist/", + "docs/", + "index.html", + "scripts/", + "src/", + "test/" + ], + "dependencies": { + "global": "^4.3.2", + "karma-safaritechpreview-launcher": "0.0.6", + "video.js": "^7.5.5", + "videojs-contrib-quality-levels": "^2.0.9" + }, + "devDependencies": { + "babel-plugin-external-helpers": "^6.22.0", + "babel-plugin-transform-object-assign": "^6.8.0", + "babel-preset-es2015": "^6.14.0", + "bannerize": "^1.1.4", + "conventional-changelog-cli": "^1.3.1", + "conventional-changelog-videojs": "^3.0.0", + "doctoc": "^1.3.0", + "husky": "^0.13.3", + "jsdoc": "^3.6.3", + "karma": "^3.1.4", + "karma-chrome-launcher": "^2.1.1", + "karma-detect-browsers": "^2.2.5", + "karma-firefox-launcher": "^1.0.1", + "karma-ie-launcher": "^1.0.0", + "karma-qunit": "^1.2.1", + "karma-safari-launcher": "^1.0.0", + "mkdirp": "^0.5.1", + "node-sass": "^4.14.1", + "node-static": "^0.7.9", + "npm-run-all": "^4.0.2", + "qunitjs": "^2.3.2", + "rimraf": "^2.6.1", + "rollup": "^0.50.0", + "rollup-plugin-babel": "^2.7.1", + "rollup-plugin-commonjs": "^8.0.2", + "rollup-plugin-json": "^2.1.1", + "rollup-plugin-multi-entry": "^2.0.1", + "rollup-plugin-node-resolve": "^3.0.0", + "rollup-watch": "^3.2.2", + "semver": "^5.3.0", + "sinon": "^2.2.0", + "uglify-js": "^3.0.7", + "videojs-standard": "^6.0.0" + } +} diff --git a/ui/component/viewers/videoViewer/internal/plugins/videojs-hls-quality-selector/plugin.js b/ui/component/viewers/videoViewer/internal/plugins/videojs-hls-quality-selector/plugin.js new file mode 100644 index 000000000..1a253073a --- /dev/null +++ b/ui/component/viewers/videoViewer/internal/plugins/videojs-hls-quality-selector/plugin.js @@ -0,0 +1,260 @@ +import videojs from 'video.js'; +import {version as VERSION} from './package.json'; +import ConcreteButton from './ConcreteButton'; +import ConcreteMenuItem from './ConcreteMenuItem'; + +// Default options for the plugin. +const defaults = {}; + +// Cross-compatibility for Video.js 5 and 6. +const registerPlugin = videojs.registerPlugin || videojs.plugin; +// const dom = videojs.dom || videojs; + +/** + * VideoJS HLS Quality Selector Plugin class. + */ +class HlsQualitySelectorPlugin { + + /** + * Plugin Constructor. + * + * @param {Player} player - The videojs player instance. + * @param {Object} options - The plugin options. + */ + constructor(player, options) { + this.player = player; + this.config = options; + + if (!this.player.qualityLevels) { + console.warn(`WARNING: Missing video.js quality levels plugin (required)`); + return; + } + + this.setupPlugin(); + } + + setupPlugin() { + // Create the quality button. + this.createQualityButton(); + + // Bind event listeners + this.bindPlayerEvents(); + + // Listen for source changes + this.player.on('loadedmetadata', (e) => { + console.log(`Loaded Metadata detected by plugin!`, e); + this.updatePlugin(); + }); + } + + updatePlugin() { + console.log(`Updating Quality Selector...`); + + // If there is quality levels plugin and the HLS tech exists + // then continue. + if (this.getHls()) { + console.log('Show quality selector'); + // Show quality selector + this._qualityButton.show(); + } else { + console.log('Hide quality selector'); + console.log('Source type does not support multiple qulaity levels...'); + // Hide quality selector + this._qualityButton.hide(); + } + } + + /** + * Returns HLS Plugin + * + * @return {*} - videojs-hls-contrib plugin. + */ + getHls() { + return this.player.tech({ IWillNotUseThisInPlugins: true }).hls; + } + + /** + * Binds listener for quality level changes. + */ + bindPlayerEvents() { + this.player.qualityLevels().on('addqualitylevel', this.onAddQualityLevel.bind(this)); + } + + /** + * Adds the quality menu button to the player control bar. + */ + createQualityButton() { + + const player = this.player; + + this._qualityButton = new ConcreteButton(player); + + const placementIndex = player.controlBar.children().length - 2; + const concreteButtonInstance = player.controlBar.addChild(this._qualityButton, + {componentClass: 'qualitySelector'}, + this.config.placementIndex || placementIndex); + + concreteButtonInstance.addClass('vjs-quality-selector'); + if (!this.config.displayCurrentQuality) { + const icon = ` ${this.config.vjsIconClass || 'vjs-icon-hd'}`; + + concreteButtonInstance + .menuButton_.$('.vjs-icon-placeholder').className += icon; + } else { + this.setButtonInnerText('auto'); + } + concreteButtonInstance.removeClass('vjs-hidden'); + + } + + /** + *Set inner button text. + * + * @param {string} text - the text to display in the button. + */ + setButtonInnerText(text) { + this._qualityButton + .menuButton_.$('.vjs-icon-placeholder').innerHTML = text; + } + + /** + * Builds individual quality menu items. + * + * @param {Object} item - Individual quality menu item. + * @return {ConcreteMenuItem} - Menu item + */ + getQualityMenuItem(item) { + const player = this.player; + + return new ConcreteMenuItem(player, item, this._qualityButton, this); + } + + /** + * Executed when a quality level is added from HLS playlist. + */ + onAddQualityLevel() { + + const player = this.player; + const qualityList = player.qualityLevels(); + const levels = qualityList.levels_ || []; + const levelItems = []; + + for (let i = 0; i < levels.length; ++i) { + if (!levelItems.filter(_existingItem => { + return _existingItem.item && _existingItem.item.value === levels[i].height; + }).length) { + const levelItem = this.getQualityMenuItem.call(this, { + label: levels[i].height + 'p', + value: levels[i].height + }); + + levelItems.push(levelItem); + } + } + + levelItems.sort((current, next) => { + if ((typeof current !== 'object') || (typeof next !== 'object')) { + return -1; + } + if (current.item.value < next.item.value) { + return -1; + } + if (current.item.value > next.item.value) { + return 1; + } + return 0; + }); + + levelItems.push(this.getQualityMenuItem.call(this, { + label: player.localize('Auto'), + value: 'auto', + selected: true + })); + + if (this._qualityButton) { + this._qualityButton.createItems = function() { + return levelItems; + }; + this._qualityButton.update(); + } + + } + + /** + * Sets quality (based on media height) + * + * @param {number} height - A number representing HLS playlist. + */ + setQuality(height) { + const qualityList = this.player.qualityLevels(); + + // Set quality on plugin + this._currentQuality = height; + + if (this.config.displayCurrentQuality) { + this.setButtonInnerText(height === 'auto' ? height : `${height}p`); + } + + for (let i = 0; i < qualityList.length; ++i) { + const quality = qualityList[i]; + + quality.enabled = (quality.height === height || height === 'auto'); + } + this._qualityButton.unpressButton(); + } + + /** + * Return the current set quality or 'auto' + * + * @return {string} the currently set quality + */ + getCurrentQuality() { + return this._currentQuality || 'auto'; + } + +} + +/** + * Function to invoke when the player is ready. + * + * This is a great place for your plugin to initialize itself. When this + * function is called, the player will have its DOM and child components + * in place. + * + * @function onPlayerReady + * @param {Player} player + * A Video.js player object. + * + * @param {Object} [options={}] + * A plain object containing options for the plugin. + */ +const onPlayerReady = (player, options) => { + player.addClass('vjs-hls-quality-selector'); + player.hlsQualitySelector = new HlsQualitySelectorPlugin(player, options); +}; + +/** + * A video.js plugin. + * + * In the plugin function, the value of `this` is a video.js `Player` + * instance. You cannot rely on the player being in a "ready" state here, + * depending on how the plugin is invoked. This may or may not be important + * to you; if not, remove the wait for "ready"! + * + * @function hlsQualitySelector + * @param {Object} [options={}] + * An object of options left to the plugin author to define. + */ +const hlsQualitySelector = function(options) { + this.ready(() => { + onPlayerReady(this, videojs.mergeOptions(defaults, options)); + }); +}; + +// Register the plugin with video.js. +registerPlugin('hlsQualitySelector', hlsQualitySelector); + +// Include the version number. +hlsQualitySelector.VERSION = VERSION; + +export default hlsQualitySelector; diff --git a/ui/component/viewers/videoViewer/internal/plugins/videojs-hls-quality-selector/plugin.scss b/ui/component/viewers/videoViewer/internal/plugins/videojs-hls-quality-selector/plugin.scss new file mode 100644 index 000000000..f0e5c7c39 --- /dev/null +++ b/ui/component/viewers/videoViewer/internal/plugins/videojs-hls-quality-selector/plugin.scss @@ -0,0 +1,9 @@ +// Sass for videojs-hls-quality-selector + +.video-js { + + // This class is added to the video.js element by the plugin by default. + &.vjs-hls-quality-selector { + display: block; + } +} diff --git a/ui/component/viewers/videoViewer/internal/videojs.jsx b/ui/component/viewers/videoViewer/internal/videojs.jsx index 4f2ef44ef..e504e8af1 100644 --- a/ui/component/viewers/videoViewer/internal/videojs.jsx +++ b/ui/component/viewers/videoViewer/internal/videojs.jsx @@ -8,7 +8,8 @@ import 'video.js/dist/alt/video-js-cdn.min.css'; import eventTracking from 'videojs-event-tracking'; import * as OVERLAY from './overlays'; import './plugins/videojs-mobile-ui/plugin'; -import qualitySelector from 'videojs-hls-quality-selector'; +// import qualitySelector from 'videojs-hls-quality-selector'; +import hlsQualitySelector from './plugins/videojs-hls-quality-selector/plugin'; import qualityLevels from 'videojs-contrib-quality-levels'; import isUserTyping from 'util/detect-typing'; @@ -102,7 +103,7 @@ if (!Object.keys(videojs.getPlugins()).includes('eventTracking')) { } if (!Object.keys(videojs.getPlugins()).includes('hlsQualitySelector')) { - videojs.registerPlugin('hlsQualitySelector', qualitySelector); + videojs.registerPlugin('hlsQualitySelector', hlsQualitySelector); } if (!Object.keys(videojs.getPlugins()).includes('qualityLevels')) { @@ -379,11 +380,6 @@ export default React.memo(function VideoJs(props: Props) { // initialize mobile UI player.mobileUi(); // Inits mobile version. No-op if Desktop. - // Add quality selector to player - player.hlsQualitySelector({ - displayCurrentQuality: true, - }); - // I think this is a callback function onPlayerReady(player); }); @@ -447,6 +443,17 @@ export default React.memo(function VideoJs(props: Props) { src: source, type: type, }); + + // Add quality selector to player + player.hlsQualitySelector({ + displayCurrentQuality: true, + }); + + // Update player source + player.src({ + src: source, + type: type, + }); }); }, [source, reload]);