diff --git a/.eslintrc b/.eslintrc index c88c7317a..462a061e6 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,30 +1,31 @@ { - "parser": "babel-eslint", - "extends": ["airbnb-base", "prettier"], - "plugins": ["simple-import-sort", "import"], - "env": { - "browser": true, - "es6": true - }, - "globals": { - "Plyr": false, - "jQuery": false - }, - "rules": { - "import/no-cycle": "warn", - "padding-line-between-statements": [ - "error", - { - "blankLine": "never", - "prev": ["singleline-const", "singleline-let", "singleline-var"], - "next": ["singleline-const", "singleline-let", "singleline-var"] - } - ], - "sort-imports": "off", - "import/order": "off", - "simple-import-sort/sort": "error" - }, - "parserOptions": { - "sourceType": "module" - } + "parser": "babel-eslint", + "extends": ["airbnb-base", "prettier"], + "plugins": ["simple-import-sort", "import"], + "env": { + "browser": true, + "es6": true + }, + "globals": { + "Plyr": false, + "jQuery": false + }, + "rules": { + "import/no-cycle": "warn", + "no-param-reassign": ["error", { "props": false }], + "padding-line-between-statements": [ + "error", + { + "blankLine": "never", + "prev": ["singleline-const", "singleline-let", "singleline-var"], + "next": ["singleline-const", "singleline-let", "singleline-var"] + } + ], + "sort-imports": "off", + "import/order": "off", + "simple-import-sort/sort": "error" + }, + "parserOptions": { + "sourceType": "module" + } } diff --git a/src/js/captions.js b/src/js/captions.js index ebb678f88..d46507b15 100644 --- a/src/js/captions.js +++ b/src/js/captions.js @@ -31,7 +31,7 @@ const captions = { } // Only Vimeo and HTML5 video supported at this point - if (!this.isVideo || this.isYouTube || (this.isHTML5 && !support.textTracks)) { + if (!this.isVideo || !this.provider.supportCaptions || (this.isHTML5 && !support.textTracks)) { // Clear menu and hide if ( is.array(this.config.controls) && diff --git a/src/js/config/defaults.js b/src/js/config/defaults.js index 7c6a708e4..3a834dc15 100644 --- a/src/js/config/defaults.js +++ b/src/js/config/defaults.js @@ -197,15 +197,6 @@ const defaults = { // URLs urls: { download: null, - vimeo: { - sdk: 'https://player.vimeo.com/api/player.js', - iframe: 'https://player.vimeo.com/video/{0}?{1}', - api: 'https://vimeo.com/api/v2/video/{0}.json', - }, - youtube: { - sdk: 'https://www.youtube.com/iframe_api', - api: 'https://noembed.com/embed?url=https://www.youtube.com/watch?v={0}', - }, googleIMA: { sdk: 'https://imasdk.googleapis.com/js/sdkloader/ima3.js', }, @@ -268,9 +259,6 @@ const defaults = { 'controlsshown', 'ready', - // YouTube - 'statechange', - // Quality 'qualitychange', @@ -414,29 +402,6 @@ const defaults = { enabled: false, src: '', }, - - // Vimeo plugin - vimeo: { - byline: false, - portrait: false, - title: false, - speed: true, - transparent: false, - // Whether the owner of the video has a Pro or Business account - // (which allows us to properly hide controls without CSS hacks, etc) - premium: false, - // Custom settings from Plyr - referrerPolicy: null, // https://developer.mozilla.org/en-US/docs/Web/API/HTMLIFrameElement/referrerPolicy - }, - - // YouTube plugin - youtube: { - noCookie: true, // Whether to use an alternative version of YouTube without cookies - rel: 0, // No related vids - showinfo: 0, // Hide info - iv_load_policy: 3, // Hide annotations - modestbranding: 1, // Hide logos as much as possible (they still show one in the corner when paused) - }, }; export default defaults; diff --git a/src/js/config/types.js b/src/js/config/types.js index 31e488eb8..4166f8054 100644 --- a/src/js/config/types.js +++ b/src/js/config/types.js @@ -2,33 +2,9 @@ // Plyr supported types and providers // ========================================================================== -export const providers = { - html5: 'html5', - youtube: 'youtube', - vimeo: 'vimeo', -}; - export const types = { audio: 'audio', video: 'video', }; -/** - * Get provider by URL - * @param {String} url - */ -export function getProviderByUrl(url) { - // YouTube - if (/^(https?:\/\/)?(www\.)?(youtube\.com|youtube-nocookie\.com|youtu\.?be)\/.+$/.test(url)) { - return providers.youtube; - } - - // Vimeo - if (/^https?:\/\/player.vimeo.com\/video\/\d{0,9}(?=\b|\/)/.test(url)) { - return providers.vimeo; - } - - return null; -} - -export default { providers, types }; +export default { types }; diff --git a/src/js/controls.js b/src/js/controls.js index ad126de11..c7dde1425 100644 --- a/src/js/controls.js +++ b/src/js/controls.js @@ -6,7 +6,6 @@ import RangeTouch from 'rangetouch'; import captions from './captions'; -import html5 from './html5'; import support from './support'; import { repaint, transitionEndEvent } from './utils/animation'; import { dedupe } from './utils/arrays'; @@ -1275,7 +1274,7 @@ const controls = { const defaultAttributes = { class: 'plyr__controls__item' }; // Loop through controls in order - dedupe(is.array(this.config.controls) ? this.config.controls: []).forEach(control => { + dedupe(is.array(this.config.controls) ? this.config.controls : []).forEach(control => { // Restart button if (control === 'restart') { container.appendChild(createButton.call(this, 'restart', defaultAttributes)); @@ -1596,9 +1595,10 @@ const controls = { } }); + const qualityOptions = this.provider.getQualityOptions(this); // Set available quality levels - if (this.isHTML5) { - setQualityMenu.call(this, html5.getQualityOptions.call(this)); + if (qualityOptions.length > 0) { + setQualityMenu.call(this, qualityOptions); } setSpeedMenu.call(this); diff --git a/src/js/html5.js b/src/js/html5.js index 658abf154..a2eb74639 100644 --- a/src/js/html5.js +++ b/src/js/html5.js @@ -2,20 +2,39 @@ // Plyr HTML5 helpers // ========================================================================== +import { types } from './config/types'; +import PlyrProvider from './plugins/providers'; import support from './support'; +import ui from './ui'; import { removeElement } from './utils/elements'; import { triggerEvent } from './utils/events'; import is from './utils/is'; import { silencePromise } from './utils/promise'; import { setAspectRatio } from './utils/style'; -const html5 = { - getSources() { - if (!this.isHTML5) { +class HTML5Provider extends PlyrProvider { + static get name() { + return 'html5'; + } + + static type(player) { + return player.media.tagName.toLowerCase() === 'video' ? types.video : types.audio; + } + + static get availableSpeed() { + return [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 4]; + } + + static get supportCaptions() { + return true; + } + + static getSources(player) { + if (!player.isHTML5) { return []; } - const sources = Array.from(this.media.querySelectorAll('source')); + const sources = Array.from(player.media.querySelectorAll('source')); // Filter out unsupported sources (if type is specified) return sources.filter(source => { @@ -25,36 +44,30 @@ const html5 = { return true; } - return support.mime.call(this, type); + return support.mime.call(player, type); }); - }, + } // Get quality levels - getQualityOptions() { + static getQualityOptions(player) { // Whether we're forcing all options (e.g. for streaming) - if (this.config.quality.forced) { - return this.config.quality.options; + if (player.config.quality.forced) { + return player.config.quality.options; } // Get sizes from elements - return html5.getSources - .call(this) + return HTML5Provider.getSources(player) .map(source => Number(source.getAttribute('size'))) .filter(Boolean); - }, + } - setup() { - if (!this.isHTML5) { + static setup(player) { + if (!player.isHTML5) { return; } - const player = this; - - // Set speed options from config - player.options.speed = player.config.speed.options; - // Set aspect ratio if fixed - if (!is.empty(this.config.ratio)) { + if (!is.empty(player.config.ratio)) { setAspectRatio.call(player); } @@ -62,7 +75,7 @@ const html5 = { Object.defineProperty(player.media, 'quality', { get() { // Get sources - const sources = html5.getSources.call(player); + const sources = HTML5Provider.getSources(player); const source = sources.find(s => s.getAttribute('src') === player.source); // Return size, if match is found @@ -78,7 +91,7 @@ const html5 = { player.config.quality.onChange(input); } else { // Get sources - const sources = html5.getSources.call(player); + const sources = HTML5Provider.getSources(player); // Get first match for requested size const source = sources.find(s => Number(s.getAttribute('size')) === input); @@ -91,13 +104,15 @@ const html5 = { const { currentTime, paused, preload, readyState, playbackRate } = player.media; // Set new source - player.media.src = source.getAttribute('src'); + player.media.setAttribute('src', source.getAttribute('src')); // Prevent loading if preload="none" and the current source isn't loaded (#1044) if (preload !== 'none' || readyState) { // Restore time player.once('loadedmetadata', () => { + // eslint-disable-next-line no-param-reassign player.speed = playbackRate; + // eslint-disable-next-line no-param-reassign player.currentTime = currentTime; // Resume playing @@ -117,31 +132,36 @@ const html5 = { }); }, }); - }, + } // Cancel current network requests // See https://github.com/sampotts/plyr/issues/174 - cancelRequests() { - if (!this.isHTML5) { + static cancelRequests(player) { + if (!player.isHTML5) { return; } // Remove child sources - removeElement(html5.getSources.call(this)); + removeElement(HTML5Provider.getSources(player)); // Set blank video src attribute // This is to prevent a MEDIA_ERR_SRC_NOT_SUPPORTED error // Info: http://stackoverflow.com/questions/32231579/how-to-properly-dispose-of-an-html5-video-and-close-socket-or-connection - this.media.setAttribute('src', this.config.blankVideo); + player.media.setAttribute('src', player.config.blankVideo); // Load the new empty source // This will cancel existing requests // See https://github.com/sampotts/plyr/issues/174 - this.media.load(); + player.media.load(); // Debugging - this.debug.log('Cancelled network requests'); - }, -}; + player.debug.log('Cancelled network requests'); + } + + static async destroy(player) { + // Restore native video controls + ui.toggleNativeControls.call(player, true); + } +} -export default html5; +export default HTML5Provider; diff --git a/src/js/media.js b/src/js/media.js index 4584fea35..822c88515 100644 --- a/src/js/media.js +++ b/src/js/media.js @@ -2,9 +2,6 @@ // Plyr Media // ========================================================================== -import html5 from './html5'; -import vimeo from './plugins/vimeo'; -import youtube from './plugins/youtube'; import { createElement, toggleClass, wrap } from './utils/elements'; const media = { @@ -20,7 +17,7 @@ const media = { toggleClass(this.elements.container, this.config.classNames.type.replace('{0}', this.type), true); // Add provider class - toggleClass(this.elements.container, this.config.classNames.provider.replace('{0}', this.provider), true); + toggleClass(this.elements.container, this.config.classNames.provider.replace('{0}', this.provider.name), true); // Add video class for embeds // This will require changes if audio embeds are added @@ -46,13 +43,16 @@ const media = { this.elements.wrapper.appendChild(this.elements.poster); } - if (this.isHTML5) { - html5.setup.call(this); - } else if (this.isYouTube) { - youtube.setup.call(this); - } else if (this.isVimeo) { - vimeo.setup.call(this); + // Some providers might not have the same speed array + // So we filter out the speeds set in config with the speed from the provider + this.setOptions({ speed: this.provider.filterSpeed(this.config.speed.options) }); + + if (this.provider === undefined) { + this.debug.warn('No provider found!'); + return; } + // Provider should be already set when we call this + this.provider.setup(this); }, }; diff --git a/src/js/plugins/providers.d.ts b/src/js/plugins/providers.d.ts new file mode 100644 index 000000000..6a41943a4 --- /dev/null +++ b/src/js/plugins/providers.d.ts @@ -0,0 +1,13 @@ +declare abstract class PlyrProvider { + static name: string; + static config: object; + static availableSpeed: Number[]; + static supportCaptions: boolean; + // Specific events for the provider to listent to + static events = []; + static type = 'audio' | 'video'; + static setup(player: Plyr): void; + static test(url: string): boolean; + static beforeSetup?(player: Plyr): void; + static async destroy(player): void; +} diff --git a/src/js/plugins/providers.js b/src/js/plugins/providers.js new file mode 100644 index 000000000..fd09bdc4f --- /dev/null +++ b/src/js/plugins/providers.js @@ -0,0 +1,84 @@ +class PlyrProvider { + // The currently available speeds for the provider + static get availableSpeed() { + return []; + } + + // Specific events for the provider to listent to + static get events() { + return []; + } + + /** Type of provider (e.g. audio or video) + * + * @param {Plyr} player + * + * @returns {string} Either audio or video + */ + // eslint-disable-next-line no-unused-vars + static type(player) { + throw new Error('You need to precise the type of the provider'); + } + + static get supportCaptions() { + return false; + } + + static getQualityOptions(player) { + // Whether we're forcing all options (e.g. for streaming) + if (player.config.quality.forced) { + return player.config.quality.options; + } + return []; + } + + /** + * Function called when setting up the plyr player + * @param {Plyr} player + */ + static setup(player) { + if (!(player instanceof Plyr)) { + throw new Error('Passed object is not an instance of Plyr'); + } + } + + /** + * Name of the provider + */ + static get name() { + throw new Error('You need to name your provider'); + } + + /** + * Test if the provider should be used for this url + * @param {String} url + * + * @return {boolean} Match + */ + // eslint-disable-next-line no-unused-vars + static test(url) { + throw new Error('You need to implement a test for your provider'); + } + + /** + * Check if the array of speed given are supported for the provider + * @param {Array} speedArray + * + * @returns {Array} The filtered out speed array that is available + */ + static filterSpeed(speedArray) { + return this.availableSpeed.filter(speed => speedArray.includes(speed)); + } + + static beforeSetup() {} + + // eslint-disable-next-line no-unused-vars + static async destroy(player) { + throw new Error('You need to implement a destroy function for your provider'); + } +} + +// The specific config for the provider +PlyrProvider.config = {}; + +export default PlyrProvider; diff --git a/src/js/plugins/vimeo.js b/src/js/plugins/vimeo.js index d098fe969..e0d04293e 100644 --- a/src/js/plugins/vimeo.js +++ b/src/js/plugins/vimeo.js @@ -3,6 +3,7 @@ // ========================================================================== import captions from '../captions'; +import { types } from '../config/types'; import controls from '../controls'; import ui from '../ui'; import { createElement, replaceElement, toggleClass } from '../utils/elements'; @@ -13,6 +14,7 @@ import loadScript from '../utils/load-script'; import { format, stripHTML } from '../utils/strings'; import { setAspectRatio } from '../utils/style'; import { buildUrlParams } from '../utils/urls'; +import PlyrProvider from './providers'; // Parse Vimeo ID from URL function parseId(url) { @@ -39,36 +41,50 @@ function assurePlaybackState(play) { } } -const vimeo = { - setup() { - const player = this; +class VimeoProvider extends PlyrProvider { + // https://github.com/vimeo/player.js/#setplaybackrateplaybackrate-number-promisenumber-rangeerrorerror + static get availableSpeed() { + return [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2]; + } + + static get name() { + return 'vimeo'; + } + + static type() { + return types.video; + } + + static get supportCaptions() { + return true; + } + + static test(url) { + return /^https?:\/\/player.vimeo.com\/video\/\d{0,9}(?=\b|\/)/.test(url); + } + static setup(player) { // Add embed class for responsive toggleClass(player.elements.wrapper, player.config.classNames.embed, true); - // Set speed options from config - player.options.speed = player.config.speed.options; - // Set intial ratio setAspectRatio.call(player); // Load the SDK if not already if (!is.object(window.Vimeo)) { - loadScript(player.config.urls.vimeo.sdk) + loadScript(player.config.vimeo.sdk) .then(() => { - vimeo.ready.call(player); + this.ready(player); }) .catch(error => { player.debug.warn('Vimeo SDK (player.js) failed to load', error); }); } else { - vimeo.ready.call(player); + this.ready(player); } - }, + } - // API Ready - ready() { - const player = this; + static ready(player) { const config = player.config.vimeo; const { premium, referrerPolicy, ...frameParams } = config; @@ -86,7 +102,7 @@ const vimeo = { autoplay: player.autoplay, muted: player.muted, gesture: 'media', - playsinline: !this.config.fullscreen.iosNative, + playsinline: !player.config.fullscreen.iosNative, ...frameParams, }); @@ -101,7 +117,7 @@ const vimeo = { const id = parseId(source); // Build an iframe const iframe = createElement('iframe'); - const src = format(player.config.urls.vimeo.iframe, id, params); + const src = format(player.config.vimeo.iframe, id, params); iframe.setAttribute('src', src); iframe.setAttribute('allowfullscreen', ''); iframe.setAttribute('allow', 'autoplay,fullscreen,picture-in-picture'); @@ -115,15 +131,15 @@ const vimeo = { const { poster } = player; if (premium) { iframe.setAttribute('data-poster', poster); - player.media = replaceElement(iframe, player.media); + player.setMedia(replaceElement(iframe, player.media)); } else { const wrapper = createElement('div', { class: player.config.classNames.embedContainer, 'data-poster': poster }); wrapper.appendChild(iframe); - player.media = replaceElement(wrapper, player.media); + player.setMedia(replaceElement(wrapper, player.media)); } // Get poster image - fetch(format(player.config.urls.vimeo.api, id), 'json').then(response => { + fetch(format(player.config.vimeo.api, id), 'json').then(response => { if (is.empty(response)) { return; } @@ -274,7 +290,7 @@ const vimeo = { controls.setDownloadUrl.call(player); }) .catch(error => { - this.debug.warn(error); + player.debug.warn(error); }); Object.defineProperty(player.media, 'currentSrc', { @@ -294,7 +310,7 @@ const vimeo = { Promise.all([player.embed.getVideoWidth(), player.embed.getVideoHeight()]).then(dimensions => { const [width, height] = dimensions; player.embed.ratio = [width, height]; - setAspectRatio.call(this); + setAspectRatio.call(player); }); // Set autopause @@ -305,7 +321,7 @@ const vimeo = { // Get title player.embed.getVideoTitle().then(title => { player.config.title = title; - ui.setTitle.call(this); + ui.setTitle.call(player); }); // Get current time @@ -408,7 +424,36 @@ const vimeo = { // Rebuild UI setTimeout(() => ui.build.call(player), 0); - }, + } + + static async destroy(player) { + // Destroy Vimeo API + // then clean up (wait, to prevent postmessage errors) + if (player.embed !== null) { + try { + await player.embed.unload(); + } catch (error) { + player.debug.warn(error); + } + } + // This function should always return, no need to timeout + } +} + +VimeoProvider.config = { + sdk: 'https://player.vimeo.com/api/player.js', + iframe: 'https://player.vimeo.com/video/{0}?{1}', + api: 'https://vimeo.com/api/v2/video/{0}.json', + byline: false, + portrait: false, + title: false, + speed: true, + transparent: false, + // Whether the owner of the video has a Pro or Business account + // (which allows us to properly hide controls without CSS hacks, etc) + premium: false, + // Custom settings from Plyr + referrerPolicy: null, // https://developer.mozilla.org/en-US/docs/Web/API/HTMLIFrameElement/referrerPolicy }; -export default vimeo; +export default VimeoProvider; diff --git a/src/js/plugins/youtube.d.ts b/src/js/plugins/youtube.d.ts new file mode 100644 index 000000000..b23a28636 --- /dev/null +++ b/src/js/plugins/youtube.d.ts @@ -0,0 +1,22 @@ +import Plyr from '../plyr'; + +declare namespace Plyr { + enum YoutubeState { + UNSTARTED = -1, + ENDED = 0, + PLAYING = 1, + PAUSED = 2, + BUFFERING = 3, + CUED = 5, + } + + interface PlyrStateChangeEvent extends CustomEvent { + readonly detail: { + readonly plyr: Plyr; + readonly code: YoutubeState; + }; + } + interface ProviderEventMap { + statechange: PlyrStateChangeEvent; + } +} diff --git a/src/js/plugins/youtube.js b/src/js/plugins/youtube.js index 89a75d895..0d59eede3 100644 --- a/src/js/plugins/youtube.js +++ b/src/js/plugins/youtube.js @@ -2,6 +2,7 @@ // YouTube plugin // ========================================================================== +import { types } from '../config/types'; import ui from '../ui'; import { createElement, replaceElement, toggleClass } from '../utils/elements'; import { triggerEvent } from '../utils/events'; @@ -12,6 +13,8 @@ import loadScript from '../utils/load-script'; import { extend } from '../utils/objects'; import { format, generateId } from '../utils/strings'; import { setAspectRatio } from '../utils/style'; +import { parseUrl } from '../utils/urls'; +import PlyrProvider from './providers'; // Parse YouTube ID from URL function parseId(url) { @@ -47,14 +50,36 @@ function getHost(config) { return undefined; } -const youtube = { - setup() { +class YouTubeProvider extends PlyrProvider { + static get name() { + return 'youtube'; + } + + static type() { + return types.video; + } + + // https://developers.google.com/youtube/iframe_api_reference#setPlaybackRate + static get availableSpeed() { + return [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 4]; + } + + // YouTube specific event + static get events() { + return ['statechange']; + } + + static test(url) { + return /^(https?:\/\/)?(www\.)?(youtube\.com|youtube-nocookie\.com|youtu\.?be)\/.+$/.test(url); + } + + static setup(player) { // Add embed class for responsive - toggleClass(this.elements.wrapper, this.config.classNames.embed, true); + toggleClass(player.elements.wrapper, player.config.classNames.embed, true); // Setup API if (is.object(window.YT) && is.function(window.YT.Player)) { - youtube.ready.call(this); + YouTubeProvider.ready(player); } else { // Reference current global callback const callback = window.onYouTubeIframeAPIReady; @@ -66,19 +91,19 @@ const youtube = { callback(); } - youtube.ready.call(this); + YouTubeProvider.ready(player); }; // Load the SDK - loadScript(this.config.urls.youtube.sdk).catch(error => { - this.debug.warn('YouTube API failed to load', error); + loadScript(player.config.youtube.sdk).catch(error => { + player.debug.warn('YouTube API failed to load', error); }); } - }, + } // Get the media title - getTitle(videoId) { - const url = format(this.config.urls.youtube.api, videoId); + static getTitle(player, videoId) { + const url = format(player.config.youtube.api, videoId); fetch(url) .then(data => { @@ -86,24 +111,22 @@ const youtube = { const { title, height, width } = data; // Set title - this.config.title = title; - ui.setTitle.call(this); + player.config.title = title; + ui.setTitle.call(player); // Set aspect ratio - this.embed.ratio = [width, height]; + player.embed.ratio = [width, height]; } - setAspectRatio.call(this); + setAspectRatio.call(player); }) .catch(() => { // Set aspect ratio - setAspectRatio.call(this); + setAspectRatio.call(player); }); - }, + } - // API ready - ready() { - const player = this; + static ready(player) { // Ignore already setup (race condition) const currentId = player.media && player.media.getAttribute('id'); if (!is.empty(currentId) && currentId.startsWith('youtube-')) { @@ -115,7 +138,7 @@ const youtube = { // Get from
if needed if (is.empty(source)) { - source = player.media.getAttribute(this.config.attributes.embed.id); + source = player.media.getAttribute(player.config.attributes.embed.id); } // Replace the