Add custom quality selector plugin

Adds custom video.js hls quality selector plugin
This allows the quality selector plugin to stay active and listen for source changes on the player to prevent the need to recreate the player when switching between MP4's and M3U8's
This commit is contained in:
DispatchCommit 2021-02-17 18:53:25 -08:00 committed by Sean Yesmunt
parent 21523fe707
commit 0fff2542b7
6 changed files with 522 additions and 7 deletions

View file

@ -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;
}
}

View file

@ -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);
}
}

View file

@ -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"
}
}

View file

@ -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;

View file

@ -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;
}
}

View file

@ -8,7 +8,8 @@ import 'video.js/dist/alt/video-js-cdn.min.css';
import eventTracking from 'videojs-event-tracking'; import eventTracking from 'videojs-event-tracking';
import * as OVERLAY from './overlays'; import * as OVERLAY from './overlays';
import './plugins/videojs-mobile-ui/plugin'; 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 qualityLevels from 'videojs-contrib-quality-levels';
import isUserTyping from 'util/detect-typing'; import isUserTyping from 'util/detect-typing';
@ -102,7 +103,7 @@ if (!Object.keys(videojs.getPlugins()).includes('eventTracking')) {
} }
if (!Object.keys(videojs.getPlugins()).includes('hlsQualitySelector')) { if (!Object.keys(videojs.getPlugins()).includes('hlsQualitySelector')) {
videojs.registerPlugin('hlsQualitySelector', qualitySelector); videojs.registerPlugin('hlsQualitySelector', hlsQualitySelector);
} }
if (!Object.keys(videojs.getPlugins()).includes('qualityLevels')) { if (!Object.keys(videojs.getPlugins()).includes('qualityLevels')) {
@ -379,11 +380,6 @@ export default React.memo<Props>(function VideoJs(props: Props) {
// initialize mobile UI // initialize mobile UI
player.mobileUi(); // Inits mobile version. No-op if Desktop. 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 // I think this is a callback function
onPlayerReady(player); onPlayerReady(player);
}); });
@ -447,6 +443,17 @@ export default React.memo<Props>(function VideoJs(props: Props) {
src: source, src: source,
type: type, type: type,
}); });
// Add quality selector to player
player.hlsQualitySelector({
displayCurrentQuality: true,
});
// Update player source
player.src({
src: source,
type: type,
});
}); });
}, [source, reload]); }, [source, reload]);