From 5500054b849214daaa31d56337940dee4237b556 Mon Sep 17 00:00:00 2001 From: Calvin Montgomery Date: Sat, 1 Jul 2017 16:50:30 -0700 Subject: [PATCH] Add resolution switcher plugin for video.js Allows switching resolutions via the video.js UI. Also added support on the player side for 540p, 1440p, and 2160p videos, although the metadata extractors have not been updated to provide these sources yet. --- package.json | 2 +- player/videojs.coffee | 15 +- templates/channel.pug | 2 + www/css/videojs-resolution-switcher.css | 31 ++ www/js/player.js | 10 +- www/js/videojs-resolution-switcher.js | 367 ++++++++++++++++++++++++ 6 files changed, 420 insertions(+), 7 deletions(-) create mode 100644 www/css/videojs-resolution-switcher.css create mode 100644 www/js/videojs-resolution-switcher.js diff --git a/package.json b/package.json index 4a4b924a..934454d0 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "author": "Calvin Montgomery", "name": "CyTube", "description": "Online media synchronizer and chat", - "version": "3.39.1", + "version": "3.39.2", "repository": { "url": "http://github.com/calzoneman/sync" }, diff --git a/player/videojs.coffee b/player/videojs.coffee index 9a3820af..f3168004 100644 --- a/player/videojs.coffee +++ b/player/videojs.coffee @@ -3,10 +3,10 @@ sortSources = (sources) -> console.error('sortSources() called with null source list') return [] - qualities = ['1080', '720', '480', '360', '240'] + qualities = ['2160', '1440', '1080', '720', '540', '480', '360', '240'] pref = String(USEROPTS.default_quality) if USEROPTS.default_quality == 'best' - pref = '1080' + pref = '2160' idx = qualities.indexOf(pref) if idx < 0 idx = 2 @@ -64,7 +64,8 @@ window.VideoJSPlayer = class VideoJSPlayer extends Player $('').attr( src: source.src type: source.type - 'data-quality': source.quality + res: source.quality + label: "#{source.quality}p #{source.type.split('/')[1]}" ).appendTo(video) ) @@ -82,7 +83,13 @@ window.VideoJSPlayer = class VideoJSPlayer extends Player ).appendTo(video) ) - @player = videojs(video[0], autoplay: true, controls: true) + @player = videojs(video[0], + autoplay: true, + controls: true, + plugins: + videoJsResolutionSwitcher: + default: @sources[0].quality + ) @player.ready(=> @player.on('error', => err = @player.error() diff --git a/templates/channel.pug b/templates/channel.pug index 1452fc2a..6ed9f7fb 100644 --- a/templates/channel.pug +++ b/templates/channel.pug @@ -5,6 +5,7 @@ html(lang="en") +head() link(href="//code.jquery.com/ui/1.10.3/themes/smoothness/jquery-ui.css", rel="stylesheet") link(rel="stylesheet", href="/css/video-js.css") + link(rel="stylesheet", href="/css/videojs-resolution-switcher.css") body #wrap nav.navbar.navbar-inverse.navbar-fixed-top(role="navigation") @@ -254,4 +255,5 @@ html(lang="en") script(defer, src="/js/froogaloop.min.js") script(defer, src="/js/video.js") script(defer, src="/js/videojs-contrib-hls.min.js") + script(defer, src="/js/videojs-resolution-switcher.js") script(defer, src="https://player.twitch.tv/js/embed/v1.js") diff --git a/www/css/videojs-resolution-switcher.css b/www/css/videojs-resolution-switcher.css new file mode 100644 index 00000000..c175c679 --- /dev/null +++ b/www/css/videojs-resolution-switcher.css @@ -0,0 +1,31 @@ +.vjs-resolution-button .vjs-menu-icon:before { + content: '\f110'; + font-family: VideoJS; + font-weight: normal; + font-style: normal; + font-size: 1.8em; + line-height: 1.67em; +} + +.vjs-resolution-button .vjs-resolution-button-label { + font-size: 1em; + line-height: 3em; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + text-align: center; + box-sizing: inherit; +} + +.vjs-resolution-button .vjs-menu .vjs-menu-content { + width: 6em; + left: 50%; /* Center the menu, in it's parent */ + margin-left: -2em; /* half of width, to center */ +} + +.vjs-resolution-button .vjs-menu li { + text-transform: none; + font-size: 1em; +} diff --git a/www/js/player.js b/www/js/player.js index 36812368..ce1933fc 100644 --- a/www/js/player.js +++ b/www/js/player.js @@ -526,7 +526,8 @@ return $('').attr({ src: source.src, type: source.type, - 'data-quality': source.quality + res: source.quality, + label: source.quality + "p " + (source.type.split('/')[1]) }).appendTo(video); }); if (data.meta.gdrive_subtitles) { @@ -546,7 +547,12 @@ } _this.player = videojs(video[0], { autoplay: true, - controls: true + controls: true, + plugins: { + videoJsResolutionSwitcher: { + "default": _this.sources[0].quality + } + } }); return _this.player.ready(function() { _this.player.on('error', function() { diff --git a/www/js/videojs-resolution-switcher.js b/www/js/videojs-resolution-switcher.js new file mode 100644 index 00000000..eae1c38b --- /dev/null +++ b/www/js/videojs-resolution-switcher.js @@ -0,0 +1,367 @@ +/*! videojs-resolution-switcher - 2015-7-26 + * Copyright (c) 2016 Kasper Moskwiak + * Modified by Pierre Kraft and Derk-Jan Hartman + * Licensed under the Apache-2.0 license. */ + +(function() { + /* jshint eqnull: true*/ + /* global require */ + 'use strict'; + var videojs = null; + if(typeof window.videojs === 'undefined' && typeof require === 'function') { + videojs = require('video.js'); + } else { + videojs = window.videojs; + } + + (function(window, videojs) { + var videoJsResolutionSwitcher, + defaults = { + ui: true + }; + + /* + * Resolution menu item + */ + var MenuItem = videojs.getComponent('MenuItem'); + var ResolutionMenuItem = videojs.extend(MenuItem, { + constructor: function(player, options){ + options.selectable = true; + // Sets this.player_, this.options_ and initializes the component + MenuItem.call(this, player, options); + this.src = options.src; + + player.on('resolutionchange', videojs.bind(this, this.update)); + } + } ); + ResolutionMenuItem.prototype.handleClick = function(event){ + MenuItem.prototype.handleClick.call(this,event); + this.player_.currentResolution(this.options_.label); + }; + ResolutionMenuItem.prototype.update = function(){ + var selection = this.player_.currentResolution(); + this.selected(this.options_.label === selection.label); + }; + MenuItem.registerComponent('ResolutionMenuItem', ResolutionMenuItem); + + /* + * Resolution menu button + */ + var MenuButton = videojs.getComponent('MenuButton'); + var ResolutionMenuButton = videojs.extend(MenuButton, { + constructor: function(player, options){ + this.label = document.createElement('span'); + options.label = 'Quality'; + // Sets this.player_, this.options_ and initializes the component + MenuButton.call(this, player, options); + this.el().setAttribute('aria-label','Quality'); + this.controlText('Quality'); + + if(options.dynamicLabel){ + videojs.addClass(this.label, 'vjs-resolution-button-label'); + this.el().appendChild(this.label); + }else{ + var staticLabel = document.createElement('span'); + videojs.addClass(staticLabel, 'vjs-menu-icon'); + this.el().appendChild(staticLabel); + } + player.on('updateSources', videojs.bind( this, this.update ) ); + } + } ); + ResolutionMenuButton.prototype.createItems = function(){ + var menuItems = []; + var labels = (this.sources && this.sources.label) || {}; + + // FIXME order is not guaranteed here. + for (var key in labels) { + if (labels.hasOwnProperty(key)) { + menuItems.push(new ResolutionMenuItem( + this.player_, + { + label: key, + src: labels[key], + selected: key === (this.currentSelection ? this.currentSelection.label : false) + }) + ); + } + } + return menuItems; + }; + ResolutionMenuButton.prototype.update = function(){ + this.sources = this.player_.getGroupedSrc(); + this.currentSelection = this.player_.currentResolution(); + this.label.innerHTML = this.currentSelection ? this.currentSelection.label : ''; + return MenuButton.prototype.update.call(this); + }; + ResolutionMenuButton.prototype.buildCSSClass = function(){ + return MenuButton.prototype.buildCSSClass.call( this ) + ' vjs-resolution-button'; + }; + MenuButton.registerComponent('ResolutionMenuButton', ResolutionMenuButton); + + /** + * Initialize the plugin. + * @param {object} [options] configuration for the plugin + */ + videoJsResolutionSwitcher = function(options) { + var settings = videojs.mergeOptions(defaults, options), + player = this, + groupedSrc = {}, + currentSources = {}, + currentResolutionState = {}; + + /** + * Updates player sources or returns current source URL + * @param {Array} [src] array of sources [{src: '', type: '', label: '', res: ''}] + * @returns {Object|String|Array} videojs player object if used as setter or current source URL, object, or array of sources + */ + player.updateSrc = function(src){ + //Return current src if src is not given + if(!src){ return player.src(); } + + // Only add those sources which we can (maybe) play + src = src.filter( function(source) { + try { + return ( player.canPlayType( source.type ) !== '' ); + } catch (e) { + // If a Tech doesn't yet have canPlayType just add it + return true; + } + }); + //Sort sources + this.currentSources = src.sort(compareResolutions); + this.groupedSrc = bucketSources(this.currentSources); + // Pick one by default + var chosen = chooseSrc(this.groupedSrc, this.currentSources); + this.currentResolutionState = { + label: chosen.label, + sources: chosen.sources + }; + + player.trigger('updateSources'); + player.setSourcesSanitized(chosen.sources, chosen.label); + player.trigger('resolutionchange'); + return player; + }; + + /** + * Returns current resolution or sets one when label is specified + * @param {String} [label] label name + * @param {Function} [customSourcePicker] custom function to choose source. Takes 2 arguments: sources, label. Must return player object. + * @returns {Object} current resolution object {label: '', sources: []} if used as getter or player object if used as setter + */ + player.currentResolution = function(label, customSourcePicker){ + if(label == null) { return this.currentResolutionState; } + + // Lookup sources for label + if(!this.groupedSrc || !this.groupedSrc.label || !this.groupedSrc.label[label]){ + return; + } + var sources = this.groupedSrc.label[label]; + // Remember player state + var currentTime = player.currentTime(); + var isPaused = player.paused(); + + // Hide bigPlayButton + if(!isPaused && this.player_.options_.bigPlayButton){ + this.player_.bigPlayButton.hide(); + } + + // Change player source and wait for loadeddata event, then play video + // loadedmetadata doesn't work right now for flash. + // Probably because of https://github.com/videojs/video-js-swf/issues/124 + // If player preload is 'none' and then loadeddata not fired. So, we need timeupdate event for seek handle (timeupdate doesn't work properly with flash) + var handleSeekEvent = 'loadeddata'; + if(this.player_.techName_ !== 'Youtube' && this.player_.preload() === 'none' && this.player_.techName_ !== 'Flash') { + handleSeekEvent = 'timeupdate'; + } + player + .setSourcesSanitized(sources, label, customSourcePicker || settings.customSourcePicker) + .one(handleSeekEvent, function() { + player.currentTime(currentTime); + player.handleTechSeeked_(); + if(!isPaused){ + // Start playing and hide loadingSpinner (flash issue ?) + player.play().handleTechSeeked_(); + } + player.trigger('resolutionchange'); + }); + return player; + }; + + /** + * Returns grouped sources by label, resolution and type + * @returns {Object} grouped sources: { label: { key: [] }, res: { key: [] }, type: { key: [] } } + */ + player.getGroupedSrc = function(){ + return this.groupedSrc; + }; + + player.setSourcesSanitized = function(sources, label, customSourcePicker) { + this.currentResolutionState = { + label: label, + sources: sources + }; + if(typeof customSourcePicker === 'function'){ + return customSourcePicker(player, sources, label); + } + player.src(sources.map(function(src) { + return {src: src.src, type: src.type, res: src.res}; + })); + return player; + }; + + /** + * Method used for sorting list of sources + * @param {Object} a - source object with res property + * @param {Object} b - source object with res property + * @returns {Number} result of comparation + */ + function compareResolutions(a, b){ + if(!a.res || !b.res){ return 0; } + return (+b.res)-(+a.res); + } + + /** + * Group sources by label, resolution and type + * @param {Array} src Array of sources + * @returns {Object} grouped sources: { label: { key: [] }, res: { key: [] }, type: { key: [] } } + */ + function bucketSources(src){ + var resolutions = { + label: {}, + res: {}, + type: {} + }; + src.map(function(source) { + initResolutionKey(resolutions, 'label', source); + initResolutionKey(resolutions, 'res', source); + initResolutionKey(resolutions, 'type', source); + + appendSourceToKey(resolutions, 'label', source); + appendSourceToKey(resolutions, 'res', source); + appendSourceToKey(resolutions, 'type', source); + }); + return resolutions; + } + + function initResolutionKey(resolutions, key, source) { + if(resolutions[key][source[key]] == null) { + resolutions[key][source[key]] = []; + } + } + + function appendSourceToKey(resolutions, key, source) { + resolutions[key][source[key]].push(source); + } + + /** + * Choose src if option.default is specified + * @param {Object} groupedSrc {res: { key: [] }} + * @param {Array} src Array of sources sorted by resolution used to find high and low res + * @returns {Object} {res: string, sources: []} + */ + function chooseSrc(groupedSrc, src){ + var selectedRes = settings['default']; // use array access as default is a reserved keyword + var selectedLabel = ''; + if (selectedRes === 'high') { + selectedRes = src[0].res; + selectedLabel = src[0].label; + } else if (selectedRes === 'low' || selectedRes == null || !groupedSrc.res[selectedRes]) { + // Select low-res if default is low or not set + selectedRes = src[src.length - 1].res; + selectedLabel = src[src.length -1].label; + } else if (groupedSrc.res[selectedRes]) { + selectedLabel = groupedSrc.res[selectedRes][0].label; + } + + return {res: selectedRes, label: selectedLabel, sources: groupedSrc.res[selectedRes]}; + } + + function initResolutionForYt(player){ + // Map youtube qualities names + var _yts = { + highres: {res: 1080, label: '1080', yt: 'highres'}, + hd1080: {res: 1080, label: '1080', yt: 'hd1080'}, + hd720: {res: 720, label: '720', yt: 'hd720'}, + large: {res: 480, label: '480', yt: 'large'}, + medium: {res: 360, label: '360', yt: 'medium'}, + small: {res: 240, label: '240', yt: 'small'}, + tiny: {res: 144, label: '144', yt: 'tiny'}, + auto: {res: 0, label: 'auto', yt: 'auto'} + }; + // Overwrite default sourcePicker function + var _customSourcePicker = function(_player, _sources, _label){ + // Note that setPlayebackQuality is a suggestion. YT does not always obey it. + player.tech_.ytPlayer.setPlaybackQuality(_sources[0]._yt); + player.trigger('updateSources'); + return player; + }; + settings.customSourcePicker = _customSourcePicker; + + // Init resolution + player.tech_.ytPlayer.setPlaybackQuality('auto'); + + // This is triggered when the resolution actually changes + player.tech_.ytPlayer.addEventListener('onPlaybackQualityChange', function(event){ + for(var res in _yts) { + if(res.yt === event.data) { + player.currentResolution(res.label, _customSourcePicker); + return; + } + } + }); + + // We must wait for play event + player.one('play', function(){ + var qualities = player.tech_.ytPlayer.getAvailableQualityLevels(); + var _sources = []; + + qualities.map(function(q){ + _sources.push({ + src: player.src().src, + type: player.src().type, + label: _yts[q].label, + res: _yts[q].res, + _yt: _yts[q].yt + }); + }); + + player.groupedSrc = bucketSources(_sources); + var chosen = {label: 'auto', res: 0, sources: player.groupedSrc.label.auto}; + + this.currentResolutionState = { + label: chosen.label, + sources: chosen.sources + }; + + player.trigger('updateSources'); + player.setSourcesSanitized(chosen.sources, chosen.label, _customSourcePicker); + }); + } + + player.ready(function(){ + if( settings.ui ) { + var menuButton = new ResolutionMenuButton(player, settings); + player.controlBar.resolutionSwitcher = player.controlBar.el_.insertBefore(menuButton.el_, player.controlBar.getChild('fullscreenToggle').el_); + player.controlBar.resolutionSwitcher.dispose = function(){ + this.parentNode.removeChild(this); + }; + } + if(player.options_.sources.length > 1){ + // tech: Html5 and Flash + // Create resolution switcher for videos form tag inside