diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/js/captions.js | 29 | ||||
-rw-r--r-- | src/js/config/defaults.js | 13 | ||||
-rw-r--r-- | src/js/controls.js | 562 | ||||
-rw-r--r-- | src/js/listeners.js | 4 | ||||
-rw-r--r-- | src/js/plugins/ads.js | 85 | ||||
-rw-r--r-- | src/js/plugins/previewThumbnails.js | 10 | ||||
-rw-r--r-- | src/js/plugins/vimeo.js | 8 | ||||
-rw-r--r-- | src/js/plugins/youtube.js | 47 | ||||
-rw-r--r-- | src/js/plyr.js | 73 | ||||
-rw-r--r-- | src/js/ui.js | 29 | ||||
-rw-r--r-- | src/js/utils/elements.js | 15 | ||||
-rw-r--r-- | src/js/utils/numbers.js | 17 | ||||
-rw-r--r-- | src/js/utils/style.js | 10 | ||||
-rw-r--r-- | src/sass/components/controls.scss | 63 | ||||
-rw-r--r-- | src/sass/components/progress.scss | 15 |
15 files changed, 544 insertions, 436 deletions
diff --git a/src/js/captions.js b/src/js/captions.js index ae4642aa..b326d85e 100644 --- a/src/js/captions.js +++ b/src/js/captions.js @@ -124,19 +124,21 @@ const captions = { // Handle tracks (add event listener and "pseudo"-default) if (this.isHTML5 && this.isVideo) { - tracks.filter(track => !meta.get(track)).forEach(track => { - this.debug.log('Track added', track); - // Attempt to store if the original dom element was "default" - meta.set(track, { - default: track.mode === 'showing', + tracks + .filter(track => !meta.get(track)) + .forEach(track => { + this.debug.log('Track added', track); + // Attempt to store if the original dom element was "default" + meta.set(track, { + default: track.mode === 'showing', + }); + + // Turn off native caption rendering to avoid double captions + track.mode = 'hidden'; + + // Add event listener for cue changes + on.call(this, track, 'cuechange', () => captions.updateCues.call(this)); }); - - // Turn off native caption rendering to avoid double captions - track.mode = 'hidden'; - - // Add event listener for cue changes - on.call(this, track, 'cuechange', () => captions.updateCues.call(this)); - }); } // Update language first time it matches, or if the previous matching track was removed @@ -300,10 +302,12 @@ const captions = { const sortIsDefault = track => Number((this.captions.meta.get(track) || {}).default); const sorted = Array.from(tracks).sort((a, b) => sortIsDefault(b) - sortIsDefault(a)); let track; + languages.every(language => { track = sorted.find(track => track.language === language); return !track; // Break iteration if there is a match }); + // If no match is found but is required, get first return track || (force ? sorted[0] : undefined); }, @@ -360,6 +364,7 @@ const captions = { // Get cues from track if (!cues) { const track = captions.getCurrentTrack.call(this); + cues = Array.from((track || {}).activeCues || []) .map(cue => cue.getCueAsHTML()) .map(getHTML); diff --git a/src/js/config/defaults.js b/src/js/config/defaults.js index f276a2df..6ba6d323 100644 --- a/src/js/config/defaults.js +++ b/src/js/config/defaults.js @@ -61,7 +61,7 @@ const defaults = { // Sprite (for icons) loadSprite: true, iconPrefix: 'plyr', - iconUrl: 'https://cdn.plyr.io/3.5.3/plyr.svg', + iconUrl: 'https://cdn.plyr.io/3.5.2/plyr.svg', // Blank video (used to prevent errors on source change) blankVideo: 'https://cdn.plyr.io/static/blank.mp4', @@ -195,8 +195,7 @@ const defaults = { }, youtube: { sdk: 'https://www.youtube.com/iframe_api', - api: - 'https://www.googleapis.com/youtube/v3/videos?id={0}&key={1}&fields=items(snippet(title))&part=snippet', + api: 'https://noembed.com/embed?url=https://www.youtube.com/watch?v={0}', // 'https://www.googleapis.com/youtube/v3/videos?id={0}&key={1}&fields=items(snippet(title),fileDetails)&part=snippet', }, googleIMA: { sdk: 'https://imasdk.googleapis.com/js/sdkloader/ima3.js', @@ -320,9 +319,6 @@ const defaults = { progress: '.plyr__progress', captions: '.plyr__captions', caption: '.plyr__caption', - menu: { - quality: '.js-plyr__menu__list--quality', - }, }, // Class hooks added to the player in different states @@ -396,11 +392,6 @@ const defaults = { }, }, - // API keys - keys: { - google: null, - }, - // Advertisements plugin // Register for an account here: http://vi.ai/publisher-video-monetization/?aid=plyrio ads: { diff --git a/src/js/controls.js b/src/js/controls.js index 73903e16..9a960b38 100644 --- a/src/js/controls.js +++ b/src/js/controls.js @@ -10,20 +10,7 @@ import support from './support'; import { repaint, transitionEndEvent } from './utils/animation'; import { dedupe } from './utils/arrays'; import browser from './utils/browser'; -import { - createElement, - emptyElement, - getAttributesFromSelector, - getElement, - getElements, - hasClass, - matches, - removeElement, - setAttributes, - setFocus, - toggleClass, - toggleHidden, -} from './utils/elements'; +import { createElement, emptyElement, getAttributesFromSelector, getElement, getElements, hasClass, matches, removeElement, setAttributes, setFocus, toggleClass, toggleHidden } from './utils/elements'; import { off, on } from './utils/events'; import i18n from './utils/i18n'; import is from './utils/is'; @@ -172,7 +159,7 @@ const controls = { // Create a <button> createButton(buttonType, attr) { - const attributes = Object.assign({}, attr); + const attributes = extend({}, attr); let type = toCamelCase(buttonType); const props = { @@ -198,8 +185,10 @@ const controls = { // Set class name if (Object.keys(attributes).includes('class')) { - if (!attributes.class.includes(this.config.classNames.control)) { - attributes.class += ` ${this.config.classNames.control}`; + if (!attributes.class.split(' ').some(c => c === this.config.classNames.control)) { + extend(attributes, { + class: `${attributes.class} ${this.config.classNames.control}`, + }); } } else { attributes.class = this.config.classNames.control; @@ -377,13 +366,13 @@ const controls = { }, // Create time display - createTime(type) { - const attributes = getAttributesFromSelector(this.config.selectors.display[type]); + createTime(type, attrs) { + const attributes = getAttributesFromSelector(this.config.selectors.display[type], attrs); const container = createElement( 'div', extend(attributes, { - class: `${this.config.classNames.display.time} ${attributes.class ? attributes.class : ''}`.trim(), + class: `${attributes.class ? attributes.class : ''} ${this.config.classNames.display.time} `.trim(), 'aria-label': i18n.get(type, this.config), }), '00:00', @@ -1138,7 +1127,10 @@ const controls = { } else if (is.keyboardEvent(input) && input.which === 27) { show = false; } else if (is.event(input)) { - const isMenuItem = popup.contains(input.target); + // If Plyr is in a shadowDOM, the event target is set to the component, instead of the + // Element in the shadowDOM. The path, if available, is complete. + const target = is.function(input.composedPath) ? input.composedPath()[0] : input.target; + const isMenuItem = popup.contains(target); // If the click was inside the menu or if the click // wasn't the button or menu item and we're trying to @@ -1191,7 +1183,7 @@ const controls = { // Show a panel in the menu showMenuPanel(type = '', tabFocus = false) { - const target = document.getElementById(`plyr-settings-${this.id}-${type}`); + const target = this.elements.container.querySelector(`#plyr-settings-${this.id}-${type}`); // Nothing to show, bail if (!is.element(target)) { @@ -1244,8 +1236,8 @@ const controls = { controls.focusFirstMenuItem.call(this, target, tabFocus); }, - // Set the download link - setDownloadLink() { + // Set the download URL + setDownloadUrl() { const button = this.elements.buttons.download; // Bail if no button @@ -1253,324 +1245,356 @@ const controls = { return; } - // Set download link + // Set attribute button.setAttribute('href', this.download); }, // Build the default HTML - // TODO: Set order based on order in the config.controls array? create(data) { - // Create the container - const container = createElement('div', getAttributesFromSelector(this.config.selectors.controls.wrapper)); + const { + bindMenuItemShortcuts, + createButton, + createProgress, + createRange, + createTime, + setQualityMenu, + setSpeedMenu, + showMenuPanel, + } = controls; + this.elements.controls = null; - // Restart button - if (this.config.controls.includes('restart')) { - container.appendChild(controls.createButton.call(this, 'restart')); + // Larger overlaid play button + if (this.config.controls.includes('play-large')) { + this.elements.container.appendChild(createButton.call(this, 'play-large')); } - // Rewind button - if (this.config.controls.includes('rewind')) { - container.appendChild(controls.createButton.call(this, 'rewind')); - } + // Create the container + const container = createElement('div', getAttributesFromSelector(this.config.selectors.controls.wrapper)); + this.elements.controls = container; - // Play/Pause button - if (this.config.controls.includes('play')) { - container.appendChild(controls.createButton.call(this, 'play')); - } + // Default item attributes + const defaultAttributes = { class: 'plyr__controls__item' }; - // Fast forward button - if (this.config.controls.includes('fast-forward')) { - container.appendChild(controls.createButton.call(this, 'fast-forward')); - } + // Loop through controls in order + dedupe(this.config.controls).forEach(control => { + // Restart button + if (control === 'restart') { + container.appendChild(createButton.call(this, 'restart', defaultAttributes)); + } - // Progress - if (this.config.controls.includes('progress')) { - const progress = createElement('div', getAttributesFromSelector(this.config.selectors.progress)); + // Rewind button + if (control === 'rewind') { + container.appendChild(createButton.call(this, 'rewind', defaultAttributes)); + } - // Seek range slider - progress.appendChild( - controls.createRange.call(this, 'seek', { - id: `plyr-seek-${data.id}`, - }), - ); + // Play/Pause button + if (control === 'play') { + container.appendChild(createButton.call(this, 'play', defaultAttributes)); + } - // Buffer progress - progress.appendChild(controls.createProgress.call(this, 'buffer')); + // Fast forward button + if (control === 'fast-forward') { + container.appendChild(createButton.call(this, 'fast-forward', defaultAttributes)); + } - // TODO: Add loop display indicator + // Progress + if (control === 'progress') { + const progressContainer = createElement('div', { + class: `${defaultAttributes.class} plyr__progress__container`, + }); - // Seek tooltip - if (this.config.tooltips.seek) { - const tooltip = createElement( - 'span', - { - class: this.config.classNames.tooltip, - }, - '00:00', - ); + const progress = createElement('div', getAttributesFromSelector(this.config.selectors.progress)); - progress.appendChild(tooltip); - this.elements.display.seekTooltip = tooltip; - } + // Seek range slider + progress.appendChild( + createRange.call(this, 'seek', { + id: `plyr-seek-${data.id}`, + }), + ); - this.elements.progress = progress; - container.appendChild(this.elements.progress); - } + // Buffer progress + progress.appendChild(createProgress.call(this, 'buffer')); - // Media current time display - if (this.config.controls.includes('current-time')) { - container.appendChild(controls.createTime.call(this, 'currentTime')); - } + // TODO: Add loop display indicator - // Media duration display - if (this.config.controls.includes('duration')) { - container.appendChild(controls.createTime.call(this, 'duration')); - } + // Seek tooltip + if (this.config.tooltips.seek) { + const tooltip = createElement( + 'span', + { + class: this.config.classNames.tooltip, + }, + '00:00', + ); - // Volume controls - if (this.config.controls.includes('mute') || this.config.controls.includes('volume')) { - const volume = createElement('div', { - class: 'plyr__volume', - }); + progress.appendChild(tooltip); + this.elements.display.seekTooltip = tooltip; + } - // Toggle mute button - if (this.config.controls.includes('mute')) { - volume.appendChild(controls.createButton.call(this, 'mute')); + this.elements.progress = progress; + progressContainer.appendChild(this.elements.progress); + container.appendChild(progressContainer); } - // Volume range control - if (this.config.controls.includes('volume')) { - // Set the attributes - const attributes = { - max: 1, - step: 0.05, - value: this.config.volume, - }; - - // Create the volume range slider - volume.appendChild( - controls.createRange.call( - this, - 'volume', - extend(attributes, { - id: `plyr-volume-${data.id}`, - }), - ), - ); - - this.elements.volume = volume; + // Media current time display + if (control === 'current-time') { + container.appendChild(createTime.call(this, 'currentTime', defaultAttributes)); } - container.appendChild(volume); - } - - // Toggle captions button - if (this.config.controls.includes('captions')) { - container.appendChild(controls.createButton.call(this, 'captions')); - } + // Media duration display + if (control === 'duration') { + container.appendChild(createTime.call(this, 'duration', defaultAttributes)); + } - // Settings button / menu - if (this.config.controls.includes('settings') && !is.empty(this.config.settings)) { - const control = createElement('div', { - class: 'plyr__menu', - hidden: '', - }); + // Volume controls + if (control === 'mute' || control === 'volume') { + let { volume } = this.elements; - control.appendChild( - controls.createButton.call(this, 'settings', { - 'aria-haspopup': true, - 'aria-controls': `plyr-settings-${data.id}`, - 'aria-expanded': false, - }), - ); + // Create the volume container if needed + if (!is.element(volume) || !container.contains(volume)) { + volume = createElement( + 'div', + extend({}, defaultAttributes, { + class: `${defaultAttributes.class} plyr__volume`.trim(), + }), + ); - const popup = createElement('div', { - class: 'plyr__menu__container', - id: `plyr-settings-${data.id}`, - hidden: '', - }); + this.elements.volume = volume; - const inner = createElement('div'); + container.appendChild(volume); + } - const home = createElement('div', { - id: `plyr-settings-${data.id}-home`, - }); + // Toggle mute button + if (control === 'mute') { + volume.appendChild(createButton.call(this, 'mute')); + } - // Create the menu - const menu = createElement('div', { - role: 'menu', - }); + // Volume range control + if (control === 'volume') { + // Set the attributes + const attributes = { + max: 1, + step: 0.05, + value: this.config.volume, + }; + + // Create the volume range slider + volume.appendChild( + createRange.call( + this, + 'volume', + extend(attributes, { + id: `plyr-volume-${data.id}`, + }), + ), + ); + } + } - home.appendChild(menu); - inner.appendChild(home); - this.elements.settings.panels.home = home; + // Toggle captions button + if (control === 'captions') { + container.appendChild(createButton.call(this, 'captions', defaultAttributes)); + } - // Build the menu items - this.config.settings.forEach(type => { - // TODO: bundle this with the createMenuItem helper and bindings - const menuItem = createElement( - 'button', - extend(getAttributesFromSelector(this.config.selectors.buttons.settings), { - type: 'button', - class: `${this.config.classNames.control} ${this.config.classNames.control}--forward`, - role: 'menuitem', - 'aria-haspopup': true, + // Settings button / menu + if (control === 'settings' && !is.empty(this.config.settings)) { + const control = createElement( + 'div', + extend({}, defaultAttributes, { + class: `${defaultAttributes.class} plyr__menu`.trim(), hidden: '', }), ); - // Bind menu shortcuts for keyboard users - controls.bindMenuItemShortcuts.call(this, menuItem, type); + control.appendChild( + createButton.call(this, 'settings', { + 'aria-haspopup': true, + 'aria-controls': `plyr-settings-${data.id}`, + 'aria-expanded': false, + }), + ); - // Show menu on click - on(menuItem, 'click', () => { - controls.showMenuPanel.call(this, type, false); + const popup = createElement('div', { + class: 'plyr__menu__container', + id: `plyr-settings-${data.id}`, + hidden: '', }); - const flex = createElement('span', null, i18n.get(type, this.config)); + const inner = createElement('div'); - const value = createElement('span', { - class: this.config.classNames.menu.value, + const home = createElement('div', { + id: `plyr-settings-${data.id}-home`, }); - // Speed contains HTML entities - value.innerHTML = data[type]; + // Create the menu + const menu = createElement('div', { + role: 'menu', + }); - flex.appendChild(value); - menuItem.appendChild(flex); - menu.appendChild(menuItem); + home.appendChild(menu); + inner.appendChild(home); + this.elements.settings.panels.home = home; + + // Build the menu items + this.config.settings.forEach(type => { + // TODO: bundle this with the createMenuItem helper and bindings + const menuItem = createElement( + 'button', + extend(getAttributesFromSelector(this.config.selectors.buttons.settings), { + type: 'button', + class: `${this.config.classNames.control} ${this.config.classNames.control}--forward`, + role: 'menuitem', + 'aria-haspopup': true, + hidden: '', + }), + ); - // Build the panes - const pane = createElement('div', { - id: `plyr-settings-${data.id}-${type}`, - hidden: '', - }); + // Bind menu shortcuts for keyboard users + bindMenuItemShortcuts.call(this, menuItem, type); - // Back button - const backButton = createElement('button', { - type: 'button', - class: `${this.config.classNames.control} ${this.config.classNames.control}--back`, - }); + // Show menu on click + on(menuItem, 'click', () => { + showMenuPanel.call(this, type, false); + }); - // Visible label - backButton.appendChild( - createElement( - 'span', - { - 'aria-hidden': true, - }, - i18n.get(type, this.config), - ), - ); + const flex = createElement('span', null, i18n.get(type, this.config)); - // Screen reader label - backButton.appendChild( - createElement( - 'span', - { - class: this.config.classNames.hidden, - }, - i18n.get('menuBack', this.config), - ), - ); + const value = createElement('span', { + class: this.config.classNames.menu.value, + }); - // Go back via keyboard - on( - pane, - 'keydown', - event => { - // We only care about <- - if (event.which !== 37) { - return; - } + // Speed contains HTML entities + value.innerHTML = data[type]; - // Prevent seek - event.preventDefault(); - event.stopPropagation(); + flex.appendChild(value); + menuItem.appendChild(flex); + menu.appendChild(menuItem); - // Show the respective menu - controls.showMenuPanel.call(this, 'home', true); - }, - false, - ); + // Build the panes + const pane = createElement('div', { + id: `plyr-settings-${data.id}-${type}`, + hidden: '', + }); - // Go back via button click - on(backButton, 'click', () => { - controls.showMenuPanel.call(this, 'home', false); - }); + // Back button + const backButton = createElement('button', { + type: 'button', + class: `${this.config.classNames.control} ${this.config.classNames.control}--back`, + }); + + // Visible label + backButton.appendChild( + createElement( + 'span', + { + 'aria-hidden': true, + }, + i18n.get(type, this.config), + ), + ); + + // Screen reader label + backButton.appendChild( + createElement( + 'span', + { + class: this.config.classNames.hidden, + }, + i18n.get('menuBack', this.config), + ), + ); + + // Go back via keyboard + on( + pane, + 'keydown', + event => { + // We only care about <- + if (event.which !== 37) { + return; + } - // Add to pane - pane.appendChild(backButton); + // Prevent seek + event.preventDefault(); + event.stopPropagation(); - // Menu - pane.appendChild( - createElement('div', { - role: 'menu', - }), - ); + // Show the respective menu + showMenuPanel.call(this, 'home', true); + }, + false, + ); - inner.appendChild(pane); + // Go back via button click + on(backButton, 'click', () => { + showMenuPanel.call(this, 'home', false); + }); - this.elements.settings.buttons[type] = menuItem; - this.elements.settings.panels[type] = pane; - }); + // Add to pane + pane.appendChild(backButton); - popup.appendChild(inner); - control.appendChild(popup); - container.appendChild(control); + // Menu + pane.appendChild( + createElement('div', { + role: 'menu', + }), + ); - this.elements.settings.popup = popup; - this.elements.settings.menu = control; - } + inner.appendChild(pane); - // Picture in picture button - if (this.config.controls.includes('pip') && support.pip) { - container.appendChild(controls.createButton.call(this, 'pip')); - } + this.elements.settings.buttons[type] = menuItem; + this.elements.settings.panels[type] = pane; + }); - // Airplay button - if (this.config.controls.includes('airplay') && support.airplay) { - container.appendChild(controls.createButton.call(this, 'airplay')); - } + popup.appendChild(inner); + control.appendChild(popup); + container.appendChild(control); - // Download button - if (this.config.controls.includes('download')) { - const attributes = { - element: 'a', - href: this.download, - target: '_blank', - }; + this.elements.settings.popup = popup; + this.elements.settings.menu = control; + } - const { download } = this.config.urls; + // Picture in picture button + if (control === 'pip' && support.pip) { + container.appendChild(createButton.call(this, 'pip', defaultAttributes)); + } - if (!is.url(download) && this.isEmbed) { - extend(attributes, { - icon: `logo-${this.provider}`, - label: this.provider, - }); + // Airplay button + if (control === 'airplay' && support.airplay) { + container.appendChild(createButton.call(this, 'airplay', defaultAttributes)); } - container.appendChild(controls.createButton.call(this, 'download', attributes)); - } + // Download button + if (control === 'download') { + const attributes = extend({}, defaultAttributes, { + element: 'a', + href: this.download, + target: '_blank', + }); - // Toggle fullscreen button - if (this.config.controls.includes('fullscreen')) { - container.appendChild(controls.createButton.call(this, 'fullscreen')); - } + const { download } = this.config.urls; - // Larger overlaid play button - if (this.config.controls.includes('play-large')) { - this.elements.container.appendChild(controls.createButton.call(this, 'play-large')); - } + if (!is.url(download) && this.isEmbed) { + extend(attributes, { + icon: `logo-${this.provider}`, + label: this.provider, + }); + } - this.elements.controls = container; + container.appendChild(createButton.call(this, 'download', attributes)); + } + + // Toggle fullscreen button + if (control === 'fullscreen') { + container.appendChild(createButton.call(this, 'fullscreen', defaultAttributes)); + } + }); // Set available quality levels if (this.isHTML5) { - controls.setQualityMenu.call(this, html5.getQualityOptions.call(this)); + setQualityMenu.call(this, html5.getQualityOptions.call(this)); } - controls.setSpeedMenu.call(this); + setSpeedMenu.call(this); return container; }, diff --git a/src/js/listeners.js b/src/js/listeners.js index 5a593b10..d4d7bb32 100644 --- a/src/js/listeners.js +++ b/src/js/listeners.js @@ -318,7 +318,7 @@ class Listeners { const target = player.elements.wrapper.firstChild; const [, y] = ratio; - const [videoX, videoY] = getAspectRatio.call(this); + const [videoX, videoY] = getAspectRatio.call(player); target.style.maxWidth = toggle ? `${(y / videoY) * videoX}px` : null; target.style.margin = toggle ? '0 auto' : null; @@ -486,7 +486,7 @@ class Listeners { // Update download link when ready and if quality changes on.call(player, player.media, 'ready qualitychange', () => { - controls.setDownloadLink.call(player); + controls.setDownloadUrl.call(player); }); // Proxy events to container diff --git a/src/js/plugins/ads.js b/src/js/plugins/ads.js index c9256b0e..2b083285 100644 --- a/src/js/plugins/ads.js +++ b/src/js/plugins/ads.js @@ -14,6 +14,20 @@ import loadScript from '../utils/loadScript'; import { formatTime } from '../utils/time'; import { buildUrlParams } from '../utils/urls'; +const destroy = instance => { + // Destroy our adsManager + if (instance.manager) { + instance.manager.destroy(); + } + + // Destroy our adsManager + if (instance.elements.displayContainer) { + instance.elements.displayContainer.destroy(); + } + + instance.elements.container.remove(); +}; + class Ads { /** * Ads constructor. @@ -63,20 +77,22 @@ class Ads { * Load the IMA SDK */ load() { - if (this.enabled) { - // Check if the Google IMA3 SDK is loaded or load it ourselves - if (!is.object(window.google) || !is.object(window.google.ima)) { - loadScript(this.player.config.urls.googleIMA.sdk) - .then(() => { - this.ready(); - }) - .catch(() => { - // Script failed to load or is blocked - this.trigger('error', new Error('Google IMA SDK failed to load')); - }); - } else { - this.ready(); - } + if (!this.enabled) { + return; + } + + // Check if the Google IMA3 SDK is loaded or load it ourselves + if (!is.object(window.google) || !is.object(window.google.ima)) { + loadScript(this.player.config.urls.googleIMA.sdk) + .then(() => { + this.ready(); + }) + .catch(() => { + // Script failed to load or is blocked + this.trigger('error', new Error('Google IMA SDK failed to load')); + }); + } else { + this.ready(); } } @@ -84,6 +100,11 @@ class Ads { * Get the ads instance ready */ ready() { + // Double check we're enabled + if (!this.enabled) { + destroy(this); + } + // Start ticking our safety timer. If the whole advertisement // thing doesn't resolve within our set time; we bail this.startSafetyTimer(12000, 'ready()'); @@ -240,9 +261,6 @@ class Ads { // Get the cue points for any mid-rolls by filtering out the pre- and post-roll this.cuePoints = this.manager.getCuePoints(); - // Set volume to match player - this.manager.setVolume(this.player.volume); - // Add listeners to the required events // Advertisement error events this.manager.addEventListener(google.ima.AdErrorEvent.Type.AD_ERROR, error => this.onAdError(error)); @@ -297,15 +315,15 @@ class Ads { triggerEvent.call(this.player, this.player.media, event); }; + // Bubble the event + dispatchEvent(event.type); + switch (event.type) { case google.ima.AdEvent.Type.LOADED: // This is the first event sent for an ad - it is possible to determine whether the // ad is a video ad or an overlay this.trigger('loaded'); - // Bubble event - dispatchEvent(event.type); - // Start countdown this.pollCountdown(true); @@ -317,15 +335,19 @@ class Ads { // console.info('Ad type: ' + event.getAd().getAdPodInfo().getPodIndex()); // console.info('Ad time: ' + event.getAd().getAdPodInfo().getTimeOffset()); + + break; + + case google.ima.AdEvent.Type.STARTED: + // Set volume to match player + this.manager.setVolume(this.player.volume); + break; case google.ima.AdEvent.Type.ALL_ADS_COMPLETED: // All ads for the current videos are done. We can now request new advertisements // in case the video is re-played - // Fire event - dispatchEvent(event.type); - // TODO: Example for what happens when a next video in a playlist would be loaded. // So here we load a new video when all ads are done. // Then we load new ads within a new adsManager. When the video @@ -350,6 +372,7 @@ class Ads { // playing when the IMA SDK is ready or has failed this.loadAds(); + break; case google.ima.AdEvent.Type.CONTENT_PAUSE_REQUESTED: @@ -357,8 +380,6 @@ class Ads { // for example display a pause button and remaining time. Fired when content should // be paused. This usually happens right before an ad is about to cover the content - dispatchEvent(event.type); - this.pauseContent(); break; @@ -369,26 +390,17 @@ class Ads { // Fired when content should be resumed. This usually happens when an ad finishes // or collapses - dispatchEvent(event.type); - this.pollCountdown(); this.resumeContent(); break; - case google.ima.AdEvent.Type.STARTED: - case google.ima.AdEvent.Type.MIDPOINT: - case google.ima.AdEvent.Type.COMPLETE: - case google.ima.AdEvent.Type.IMPRESSION: - case google.ima.AdEvent.Type.CLICK: - dispatchEvent(event.type); - break; - case google.ima.AdEvent.Type.LOG: if (adData.adError) { this.player.debug.warn(`Non-fatal ad error: ${adData.adError.getMessage()}`); } + break; default: @@ -463,6 +475,9 @@ class Ads { // Play the requested advertisement whenever the adsManager is ready this.managerPromise .then(() => { + // Set volume to match player + this.manager.setVolume(this.player.volume); + // Initialize the container. Must be done via a user action on mobile devices this.elements.displayContainer.initialize(); diff --git a/src/js/plugins/previewThumbnails.js b/src/js/plugins/previewThumbnails.js index 813bc47e..3e4b17a3 100644 --- a/src/js/plugins/previewThumbnails.js +++ b/src/js/plugins/previewThumbnails.js @@ -149,9 +149,11 @@ class PreviewThumbnails { // If the URLs don't start with '/', then we need to set their relative path to be the location of the VTT file // If the URLs do start with '/', then they obviously don't need a prefix, so it will remain blank // If the thumbnail URLs start with with none of '/', 'http://' or 'https://', then we need to set their relative path to be the location of the VTT file - if (!thumbnail.frames[0].text.startsWith('/') && + if ( + !thumbnail.frames[0].text.startsWith('/') && !thumbnail.frames[0].text.startsWith('http://') && - !thumbnail.frames[0].text.startsWith('https://')) { + !thumbnail.frames[0].text.startsWith('https://') + ) { thumbnail.urlPrefix = url.substring(0, url.lastIndexOf('/') + 1); } @@ -297,7 +299,9 @@ class PreviewThumbnails { this.elements.thumb.container.appendChild(timeContainer); // Inject the whole thumb - this.player.elements.progress.appendChild(this.elements.thumb.container); + if (is.element(this.player.elements.progress)) { + this.player.elements.progress.appendChild(this.elements.thumb.container); + } // Create HTML element: plyr__preview-scrubbing-container this.elements.scrubbing.container = createElement('div', { diff --git a/src/js/plugins/vimeo.js b/src/js/plugins/vimeo.js index 9d6c1665..8d920eea 100644 --- a/src/js/plugins/vimeo.js +++ b/src/js/plugins/vimeo.js @@ -48,14 +48,14 @@ const vimeo = { // Set intial ratio setAspectRatio.call(this); - // Load the API if not already + // Load the SDK if not already if (!is.object(window.Vimeo)) { loadScript(this.config.urls.vimeo.sdk) .then(() => { vimeo.ready.call(this); }) .catch(error => { - this.debug.warn('Vimeo API failed to load', error); + this.debug.warn('Vimeo SDK (player.js) failed to load', error); }); } else { vimeo.ready.call(this); @@ -259,7 +259,7 @@ const vimeo = { .getVideoUrl() .then(value => { currentSrc = value; - controls.setDownloadLink.call(player); + controls.setDownloadUrl.call(player); }) .catch(error => { this.debug.warn(error); @@ -281,7 +281,7 @@ const vimeo = { // Set aspect ratio based on video size Promise.all([player.embed.getVideoWidth(), player.embed.getVideoHeight()]).then(dimensions => { const [width, height] = dimensions; - player.embed.ratio = `${width}:${height}`; + player.embed.ratio = [width, height]; setAspectRatio.call(this); }); diff --git a/src/js/plugins/youtube.js b/src/js/plugins/youtube.js index d862e4dd..7abc05fe 100644 --- a/src/js/plugins/youtube.js +++ b/src/js/plugins/youtube.js @@ -52,9 +52,6 @@ const youtube = { // Add embed class for responsive toggleClass(this.elements.wrapper, this.config.classNames.embed, true); - // Set aspect ratio - setAspectRatio.call(this); - // Setup API if (is.object(window.YT) && is.function(window.YT.Player)) { youtube.ready.call(this); @@ -84,33 +81,27 @@ const youtube = { // Get the media title getTitle(videoId) { - // Try via undocumented API method first - // This method disappears now and then though... - // https://github.com/sampotts/plyr/issues/709 - if (is.function(this.embed.getVideoData)) { - const { title } = this.embed.getVideoData(); - - if (is.empty(title)) { - this.config.title = title; - ui.setTitle.call(this); - return; - } - } + const url = format(this.config.urls.youtube.api, videoId); - // Or via Google API - const key = this.config.keys.google; - if (is.string(key) && !is.empty(key)) { - const url = format(this.config.urls.youtube.api, videoId, key); + fetch(url) + .then(data => { + if (is.object(data)) { + const { title, height, width } = data; - fetch(url) - .then(result => { - if (is.object(result)) { - this.config.title = result.items[0].snippet.title; - ui.setTitle.call(this); - } - }) - .catch(() => {}); - } + // Set title + this.config.title = title; + ui.setTitle.call(this); + + // Set aspect ratio + this.embed.ratio = [width, height]; + } + + setAspectRatio.call(this); + }) + .catch(() => { + // Set aspect ratio + setAspectRatio.call(this); + }); }, // API ready diff --git a/src/js/plyr.js b/src/js/plyr.js index c252d052..e81e073e 100644 --- a/src/js/plyr.js +++ b/src/js/plyr.js @@ -25,6 +25,7 @@ import { createElement, hasClass, removeElement, replaceElement, toggleClass, wr import { off, on, once, triggerEvent, unbindListeners } from './utils/events'; import is from './utils/is'; import loadSprite from './utils/loadSprite'; +import { clamp } from './utils/numbers'; import { cloneDeep, extend } from './utils/objects'; import { getAspectRatio, reduceAspectRatio, setAspectRatio, validateRatio } from './utils/style'; import { parseUrl } from './utils/urls'; @@ -661,24 +662,17 @@ class Plyr { speed = this.config.speed.selected; } - // Set min/max - if (speed < 0.1) { - speed = 0.1; - } - if (speed > 2.0) { - speed = 2.0; - } - - if (!this.config.speed.options.includes(speed)) { - this.debug.warn(`Unsupported speed (${speed})`); - return; - } + // Clamp to min/max + const { minimumSpeed: min, maximumSpeed: max } = this; + speed = clamp(speed, min, max); // Update config this.config.speed.selected = speed; // Set media speed - this.media.playbackRate = speed; + setTimeout(() => { + this.media.playbackRate = speed; + }, 0); } /** @@ -689,6 +683,42 @@ class Plyr { } /** + * Get the minimum allowed speed + */ + get minimumSpeed() { + if (this.isYouTube) { + // https://developers.google.com/youtube/iframe_api_reference#setPlaybackRate + return Math.min(...this.options.speed); + } + + if (this.isVimeo) { + // https://github.com/vimeo/player.js/#setplaybackrateplaybackrate-number-promisenumber-rangeerrorerror + return 0.5; + } + + // https://stackoverflow.com/a/32320020/1191319 + return 0.0625; + } + + /** + * Get the maximum allowed speed + */ + get maximumSpeed() { + if (this.isYouTube) { + // https://developers.google.com/youtube/iframe_api_reference#setPlaybackRate + return Math.max(...this.options.speed); + } + + if (this.isVimeo) { + // https://github.com/vimeo/player.js/#setplaybackrateplaybackrate-number-promisenumber-rangeerrorerror + return 2; + } + + // https://stackoverflow.com/a/32320020/1191319 + return 16; + } + + /** * Set playback quality * Currently HTML5 & YouTube only * @param {Number} input - Quality level @@ -824,6 +854,19 @@ class Plyr { } /** + * Set the download URL + */ + set download(input) { + if (!is.url(input)) { + return; + } + + this.config.urls.download = input; + + controls.setDownloadUrl.call(this); + } + + /** * Set the poster image for a video * @param {String} input - the URL for the new poster image */ @@ -851,6 +894,10 @@ class Plyr { * Get the current aspect ratio in use */ get ratio() { + if (!this.isVideo) { + return null; + } + const ratio = reduceAspectRatio(getAspectRatio.call(this)); return is.array(ratio) ? ratio.join(':') : ratio; diff --git a/src/js/ui.js b/src/js/ui.js index 8e50bb83..50de7df1 100644 --- a/src/js/ui.js +++ b/src/js/ui.js @@ -67,15 +67,15 @@ const ui = { // Reset mute state this.muted = null; - // Reset speed - this.speed = null; - // Reset loop state this.loop = null; // Reset quality setting this.quality = null; + // Reset speed + this.speed = null; + // Reset volume display controls.updateVolume.call(this); @@ -233,13 +233,16 @@ const ui = { clearTimeout(this.timers.loading); // Timer to prevent flicker when seeking - this.timers.loading = setTimeout(() => { - // Update progress bar loading class state - toggleClass(this.elements.container, this.config.classNames.loading, this.loading); - - // Update controls visibility - ui.toggleControls.call(this); - }, this.loading ? 250 : 0); + this.timers.loading = setTimeout( + () => { + // Update progress bar loading class state + toggleClass(this.elements.container, this.config.classNames.loading, this.loading); + + // Update controls visibility + ui.toggleControls.call(this); + }, + this.loading ? 250 : 0, + ); }, // Toggle controls based on state and `force` argument @@ -248,10 +251,12 @@ const ui = { if (controls && this.config.hideControls) { // Don't hide controls if a touch-device user recently seeked. (Must be limited to touch devices, or it occasionally prevents desktop controls from hiding.) - const recentTouchSeek = (this.touch && this.lastSeekTime + 2000 > Date.now()); + const recentTouchSeek = this.touch && this.lastSeekTime + 2000 > Date.now(); // Show controls if force, loading, paused, button interaction, or recent seek, otherwise hide - this.toggleControls(Boolean(force || this.loading || this.paused || controls.pressed || controls.hover || recentTouchSeek)); + this.toggleControls( + Boolean(force || this.loading || this.paused || controls.pressed || controls.hover || recentTouchSeek), + ); } }, }; diff --git a/src/js/utils/elements.js b/src/js/utils/elements.js index 6be634e5..9c1ddebc 100644 --- a/src/js/utils/elements.js +++ b/src/js/utils/elements.js @@ -4,6 +4,7 @@ import { toggleListener } from './events'; import is from './is'; +import { extend } from './objects'; // Wrap an element export function wrap(elements, wrapper) { @@ -137,7 +138,7 @@ export function getAttributesFromSelector(sel, existingAttributes) { } const attributes = {}; - const existing = existingAttributes; + const existing = extend({}, existingAttributes); sel.split(',').forEach(s => { // Remove whitespace @@ -147,7 +148,7 @@ export function getAttributesFromSelector(sel, existingAttributes) { // Get the parts and value const parts = stripped.split('='); - const key = parts[0]; + const [key] = parts; const value = parts.length > 1 ? parts[1].replace(/["']/g, '') : ''; // Get the first character @@ -156,11 +157,11 @@ export function getAttributesFromSelector(sel, existingAttributes) { switch (start) { case '.': // Add to existing classname - if (is.object(existing) && is.string(existing.class)) { - existing.class += ` ${className}`; + if (is.string(existing.class)) { + attributes.class = `${existing.class} ${className}`; + } else { + attributes.class = className; } - - attributes.class = className; break; case '#': @@ -179,7 +180,7 @@ export function getAttributesFromSelector(sel, existingAttributes) { } }); - return attributes; + return extend(existing, attributes); } // Toggle hidden diff --git a/src/js/utils/numbers.js b/src/js/utils/numbers.js new file mode 100644 index 00000000..f6eb65c8 --- /dev/null +++ b/src/js/utils/numbers.js @@ -0,0 +1,17 @@ +/** + * Returns a number whose value is limited to the given range. + * + * Example: limit the output of this computation to between 0 and 255 + * (x * 255).clamp(0, 255) + * + * @param {Number} input + * @param {Number} min The lower boundary of the output range + * @param {Number} max The upper boundary of the output range + * @returns A number in the range [min, max] + * @type Number + */ +export function clamp(input = 0, min = 0, max = 255) { + return Math.min(Math.max(input, min), max); +} + +export default { clamp }; diff --git a/src/js/utils/style.js b/src/js/utils/style.js index 191e6461..e51892e5 100644 --- a/src/js/utils/style.js +++ b/src/js/utils/style.js @@ -44,8 +44,14 @@ export function getAspectRatio(input) { } // Get from embed - if (ratio === null && !is.empty(this.embed) && is.string(this.embed.ratio)) { - ratio = parse(this.embed.ratio); + if (ratio === null && !is.empty(this.embed) && is.array(this.embed.ratio)) { + ({ ratio } = this.embed); + } + + // Get from HTML5 video + if (ratio === null && this.isHTML5) { + const { videoWidth, videoHeight } = this.media; + ratio = reduceAspectRatio([videoWidth, videoHeight]); } return ratio; diff --git a/src/sass/components/controls.scss b/src/sass/components/controls.scss index 41426e8b..87494957 100644 --- a/src/sass/components/controls.scss +++ b/src/sass/components/controls.scss @@ -14,42 +14,46 @@ justify-content: flex-end; text-align: center; - // Spacing - > .plyr__control, - .plyr__progress, - .plyr__time, - .plyr__menu, - .plyr__volume { - margin-left: ($plyr-control-spacing / 2); + .plyr__progress__container { + flex: 1; } - .plyr__menu + .plyr__control, - > .plyr__control + .plyr__menu, - > .plyr__control + .plyr__control, - .plyr__progress + .plyr__control { - margin-left: floor($plyr-control-spacing / 4); - } + // Spacing + .plyr__controls__item { + margin-left: ($plyr-control-spacing / 4); + + &:first-child { + margin-left: 0; + margin-right: auto; + } + + &.plyr__progress__container { + padding-left: ($plyr-control-spacing / 4); + } + + &.plyr__time { + padding: 0 ($plyr-control-spacing / 2); + } - > .plyr__control:first-child, - > .plyr__control:first-child + [data-plyr='pause'] { - margin-left: 0; - margin-right: auto; + &.plyr__progress__container:first-child, + &.plyr__time:first-child, + &.plyr__time + .plyr__time { + padding-left: 0; + } + + &.plyr__volume { + padding-right: ($plyr-control-spacing / 2); + } + + &.plyr__volume:first-child { + padding-right: 0; + } } // Hide empty controls &:empty { display: none; } - - @media (min-width: $plyr-bp-sm) { - > .plyr__control, - .plyr__menu, - .plyr__progress, - .plyr__time, - .plyr__volume { - margin-left: $plyr-control-spacing; - } - } } // Audio controls @@ -62,10 +66,7 @@ // Video controls .plyr--video .plyr__controls { - background: linear-gradient( - rgba($plyr-video-controls-bg, 0), - rgba($plyr-video-controls-bg, 0.7) - ); + background: linear-gradient(rgba($plyr-video-controls-bg, 0), rgba($plyr-video-controls-bg, 0.7)); border-bottom-left-radius: inherit; border-bottom-right-radius: inherit; bottom: 0; diff --git a/src/sass/components/progress.scss b/src/sass/components/progress.scss index f28a19ca..04c83516 100644 --- a/src/sass/components/progress.scss +++ b/src/sass/components/progress.scss @@ -2,18 +2,19 @@ // Playback progress // -------------------------------------------------------------- +// Offset the range thumb in order to be able to calculate the relative progress (#954) +$plyr-progress-offset: $plyr-range-thumb-height; + .plyr__progress { - flex: 1; - left: $plyr-range-thumb-height / 2; - margin-right: $plyr-range-thumb-height; + left: $plyr-progress-offset / 2; + margin-right: $plyr-progress-offset; position: relative; input[type='range'], &__buffer { - margin-left: -($plyr-range-thumb-height / 2); - margin-right: -($plyr-range-thumb-height / 2); - // Offset the range thumb in order to be able to calculate the relative progress (#954) - width: calc(100% + #{$plyr-range-thumb-height}); + margin-left: -($plyr-progress-offset / 2); + margin-right: -($plyr-progress-offset / 2); + width: calc(100% + #{$plyr-progress-offset}); } input[type='range'] { |