diff options
Diffstat (limited to 'src/js')
-rw-r--r-- | src/js/config/defaults.js | 6 | ||||
-rw-r--r-- | src/js/config/types.js | 2 | ||||
-rw-r--r-- | src/js/controls.js | 146 | ||||
-rw-r--r-- | src/js/listeners.js | 52 | ||||
-rw-r--r-- | src/js/plugins/vimeo.js | 3 | ||||
-rw-r--r-- | src/js/plyr.js | 12 | ||||
-rw-r--r-- | src/js/ui.js | 7 | ||||
-rw-r--r-- | src/js/utils/i18n.js | 13 | ||||
-rw-r--r-- | src/js/utils/is.js | 5 |
9 files changed, 173 insertions, 73 deletions
diff --git a/src/js/config/defaults.js b/src/js/config/defaults.js index e6e2d7c1..7d0ca7d0 100644 --- a/src/js/config/defaults.js +++ b/src/js/config/defaults.js @@ -133,6 +133,7 @@ const defaults = { 'settings', 'pip', 'airplay', + // 'download', 'fullscreen', ], settings: ['captions', 'quality', 'speed'], @@ -155,6 +156,7 @@ const defaults = { unmute: 'Unmute', enableCaptions: 'Enable captions', disableCaptions: 'Disable captions', + download: 'Download', enterFullscreen: 'Enter fullscreen', exitFullscreen: 'Exit fullscreen', frameTitle: 'Player for {title}', @@ -184,6 +186,7 @@ const defaults = { // URLs urls: { + download: null, vimeo: { sdk: 'https://player.vimeo.com/api/player.js', iframe: 'https://player.vimeo.com/video/{0}?{1}', @@ -210,6 +213,7 @@ const defaults = { mute: null, volume: null, captions: null, + download: null, fullscreen: null, pip: null, airplay: null, @@ -245,6 +249,7 @@ const defaults = { 'cuechange', // Custom events + 'download', 'enterfullscreen', 'exitfullscreen', 'captionsenabled', @@ -290,6 +295,7 @@ const defaults = { fastForward: '[data-plyr="fast-forward"]', mute: '[data-plyr="mute"]', captions: '[data-plyr="captions"]', + download: '[data-plyr="download"]', fullscreen: '[data-plyr="fullscreen"]', pip: '[data-plyr="pip"]', airplay: '[data-plyr="airplay"]', diff --git a/src/js/config/types.js b/src/js/config/types.js index 13303573..c9d50937 100644 --- a/src/js/config/types.js +++ b/src/js/config/types.js @@ -15,7 +15,7 @@ export const types = { /** * Get provider by URL - * @param {string} url + * @param {String} url */ export function getProviderByUrl(url) { // YouTube diff --git a/src/js/controls.js b/src/js/controls.js index 661ceb32..4f453e6a 100644 --- a/src/js/controls.js +++ b/src/js/controls.js @@ -111,10 +111,11 @@ const controls = { // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/xlink:href if ('href' in use) { use.setAttributeNS('http://www.w3.org/1999/xlink', 'href', path); - } else { - use.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', path); } + // Always set the older attribute even though it's "deprecated" (it'll be around for ages) + use.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', path); + // Add <use> to <svg> icon.appendChild(use); @@ -122,17 +123,13 @@ const controls = { }, // Create hidden text label - createLabel(type, attr = {}) { - // Skip i18n for abbreviations and brand names - const universals = { - pip: 'PIP', - airplay: 'AirPlay', - }; - const text = universals[type] || i18n.get(type, this.config); + createLabel(key, attr = {}) { + const text = i18n.get(key, this.config); const attributes = Object.assign({}, attr, { class: [attr.class, this.config.classNames.hidden].filter(Boolean).join(' '), }); + return createElement('span', attributes, text); }, @@ -161,21 +158,32 @@ const controls = { // Create a <button> createButton(buttonType, attr) { - const button = createElement('button'); const attributes = Object.assign({}, attr); let type = toCamelCase(buttonType); - let toggle = false; - let label; - let icon; - let labelPressed; - let iconPressed; + const props = { + element: 'button', + toggle: false, + label: null, + icon: null, + labelPressed: null, + iconPressed: null, + }; + + ['element', 'icon', 'label'].forEach(key => { + if (Object.keys(attributes).includes(key)) { + props[key] = attributes[key]; + delete attributes[key]; + } + }); - if (!('type' in attributes)) { + // Default to 'button' type to prevent form submission + if (props.element === 'button' && !Object.keys(attributes).includes('type')) { attributes.type = 'button'; } - if ('class' in attributes) { + // Set class name + if (Object.keys(attributes).includes('class')) { if (!attributes.class.includes(this.config.classNames.control)) { attributes.class += ` ${this.config.classNames.control}`; } @@ -186,82 +194,87 @@ const controls = { // Large play button switch (buttonType) { case 'play': - toggle = true; - label = 'play'; - labelPressed = 'pause'; - icon = 'play'; - iconPressed = 'pause'; + props.toggle = true; + props.label = 'play'; + props.labelPressed = 'pause'; + props.icon = 'play'; + props.iconPressed = 'pause'; break; case 'mute': - toggle = true; - label = 'mute'; - labelPressed = 'unmute'; - icon = 'volume'; - iconPressed = 'muted'; + props.toggle = true; + props.label = 'mute'; + props.labelPressed = 'unmute'; + props.icon = 'volume'; + props.iconPressed = 'muted'; break; case 'captions': - toggle = true; - label = 'enableCaptions'; - labelPressed = 'disableCaptions'; - icon = 'captions-off'; - iconPressed = 'captions-on'; + props.toggle = true; + props.label = 'enableCaptions'; + props.labelPressed = 'disableCaptions'; + props.icon = 'captions-off'; + props.iconPressed = 'captions-on'; break; case 'fullscreen': - toggle = true; - label = 'enterFullscreen'; - labelPressed = 'exitFullscreen'; - icon = 'enter-fullscreen'; - iconPressed = 'exit-fullscreen'; + props.toggle = true; + props.label = 'enterFullscreen'; + props.labelPressed = 'exitFullscreen'; + props.icon = 'enter-fullscreen'; + props.iconPressed = 'exit-fullscreen'; break; case 'play-large': attributes.class += ` ${this.config.classNames.control}--overlaid`; type = 'play'; - label = 'play'; - icon = 'play'; + props.label = 'play'; + props.icon = 'play'; break; default: - label = type; - icon = buttonType; + if (is.empty(props.label)) { + props.label = type; + } + if (is.empty(props.icon)) { + props.icon = buttonType; + } } + const button = createElement(props.element); + // Setup toggle icon and labels - if (toggle) { + if (props.toggle) { // Icon button.appendChild( - controls.createIcon.call(this, iconPressed, { + controls.createIcon.call(this, props.iconPressed, { class: 'icon--pressed', }), ); button.appendChild( - controls.createIcon.call(this, icon, { + controls.createIcon.call(this, props.icon, { class: 'icon--not-pressed', }), ); // Label/Tooltip button.appendChild( - controls.createLabel.call(this, labelPressed, { + controls.createLabel.call(this, props.labelPressed, { class: 'label--pressed', }), ); button.appendChild( - controls.createLabel.call(this, label, { + controls.createLabel.call(this, props.label, { class: 'label--not-pressed', }), ); } else { - button.appendChild(controls.createIcon.call(this, icon)); - button.appendChild(controls.createLabel.call(this, label)); + button.appendChild(controls.createIcon.call(this, props.icon)); + button.appendChild(controls.createLabel.call(this, props.label)); } - // Merge attributes + // Merge and set attributes extend(attributes, getAttributesFromSelector(this.config.selectors.buttons[type], attributes)); - setAttributes(button, attributes); // We have multiple play buttons @@ -1214,6 +1227,19 @@ const controls = { controls.focusFirstMenuItem.call(this, target, tabFocus); }, + // Set the download link + setDownloadLink() { + const button = this.elements.buttons.download; + + // Bail if no button + if (!is.element(button)) { + return; + } + + // Set download link + button.setAttribute('href', this.download); + }, + // Build the default HTML // TODO: Set order based on order in the config.controls array? create(data) { @@ -1490,6 +1516,26 @@ const controls = { container.appendChild(controls.createButton.call(this, 'airplay')); } + // Download button + if (this.config.controls.includes('download')) { + const attributes = { + element: 'a', + href: this.download, + target: '_blank', + }; + + const { download } = this.config.urls; + + if (!is.url(download) && this.isEmbed) { + extend(attributes, { + icon: `logo-${this.provider}`, + label: this.provider, + }); + } + + container.appendChild(controls.createButton.call(this, 'download', attributes)); + } + // Toggle fullscreen button if (this.config.controls.includes('fullscreen')) { container.appendChild(controls.createButton.call(this, 'fullscreen')); diff --git a/src/js/listeners.js b/src/js/listeners.js index 0a04bd99..f8ea997f 100644 --- a/src/js/listeners.js +++ b/src/js/listeners.js @@ -371,7 +371,7 @@ class Listeners { return; } - // On click play, pause ore restart + // On click play, pause or restart on.call(player, elements.container, 'click', event => { const targets = [elements.container, wrapper]; @@ -431,6 +431,11 @@ class Listeners { controls.updateSetting.call(player, 'quality', null, event.detail.quality); }); + // Update download link when ready and if quality changes + on.call(player, player.media, 'ready qualitychange', () => { + controls.setDownloadLink.call(player); + }); + // Proxy events to container // Bubble up key events for Edge const proxyEvents = player.config.events.concat(['keyup', 'keydown']).join(' '); @@ -517,6 +522,16 @@ class Listeners { // Captions toggle this.bind(elements.buttons.captions, 'click', () => player.toggleCaptions()); + // Download + this.bind( + elements.buttons.download, + 'click', + () => { + triggerEvent.call(player, player.media, 'download'); + }, + 'download', + ); + // Fullscreen toggle this.bind( elements.buttons.fullscreen, @@ -605,6 +620,9 @@ class Listeners { return; } + // Record seek time so we can prevent hiding controls for a few seconds after seek + player.lastSeekTime = Date.now(); + // Was playing before? const play = seek.hasAttribute(attribute); @@ -697,33 +715,29 @@ class Listeners { elements.controls.pressed = ['mousedown', 'touchstart'].includes(event.type); }); - // Focus in/out on controls - this.bind(elements.controls, 'focusin focusout', event => { + // Show controls when they receive focus (e.g., when using keyboard tab key) + this.bind(elements.controls, 'focusin', () => { const { config, elements, timers } = player; - const isFocusIn = event.type === 'focusin'; // Skip transition to prevent focus from scrolling the parent element - toggleClass(elements.controls, config.classNames.noTransition, isFocusIn); + toggleClass(elements.controls, config.classNames.noTransition, true); // Toggle - ui.toggleControls.call(player, isFocusIn); + ui.toggleControls.call(player, true); - // If focusin, hide again after delay - if (isFocusIn) { - // Restore transition - setTimeout(() => { - toggleClass(elements.controls, config.classNames.noTransition, false); - }, 0); + // Restore transition + setTimeout(() => { + toggleClass(elements.controls, config.classNames.noTransition, false); + }, 0); - // Delay a little more for keyboard users - const delay = this.touch ? 3000 : 4000; + // Delay a little more for mouse users + const delay = this.touch ? 3000 : 4000; - // Clear timer - clearTimeout(timers.controls); + // Clear timer + clearTimeout(timers.controls); - // Hide - timers.controls = setTimeout(() => ui.toggleControls.call(player, false), delay); - } + // Hide again after delay + timers.controls = setTimeout(() => ui.toggleControls.call(player, false), delay); }); // Mouse wheel for volume diff --git a/src/js/plugins/vimeo.js b/src/js/plugins/vimeo.js index 4cbdb5c4..3c3dee20 100644 --- a/src/js/plugins/vimeo.js +++ b/src/js/plugins/vimeo.js @@ -71,7 +71,7 @@ const vimeo = { // For Vimeo we have an extra 300% height <div> to hide the standard controls and UI setAspectRatio(input) { const [x, y] = (is.string(input) ? input : this.config.ratio).split(':'); - const padding = 100 / x * y; + const padding = (100 / x) * y; this.elements.wrapper.style.paddingBottom = `${padding}%`; if (this.supported.ui) { @@ -278,6 +278,7 @@ const vimeo = { .getVideoUrl() .then(value => { currentSrc = value; + controls.setDownloadLink.call(player); }) .catch(error => { this.debug.warn(error); diff --git a/src/js/plyr.js b/src/js/plyr.js index 77582dd7..32038b0e 100644 --- a/src/js/plyr.js +++ b/src/js/plyr.js @@ -302,6 +302,9 @@ class Plyr { if (this.config.autoplay) { this.play(); } + + // Seek time will be recorded (in listeners.js) so we can prevent hiding controls for a few seconds after seek + this.lastSeekTime = 0; } // --------------------------------------- @@ -789,6 +792,15 @@ class Plyr { } /** + * Get a download URL (either source or custom) + */ + get download() { + const { download } = this.config.urls; + + return is.url(download) ? download : this.source; + } + + /** * Set the poster image for a video * @param {input} - the URL for the new poster image */ diff --git a/src/js/ui.js b/src/js/ui.js index f0c898bf..8e50bb83 100644 --- a/src/js/ui.js +++ b/src/js/ui.js @@ -247,8 +247,11 @@ const ui = { const { controls } = this.elements; if (controls && this.config.hideControls) { - // Show controls if force, loading, paused, or button interaction, otherwise hide - this.toggleControls(Boolean(force || this.loading || this.paused || controls.pressed || controls.hover)); + // 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()); + + // 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)); } }, }; diff --git a/src/js/utils/i18n.js b/src/js/utils/i18n.js index f71e1a42..758ed695 100644 --- a/src/js/utils/i18n.js +++ b/src/js/utils/i18n.js @@ -6,6 +6,15 @@ import is from './is'; import { getDeep } from './objects'; import { replaceAll } from './strings'; +// Skip i18n for abbreviations and brand names +const resources = { + pip: 'PIP', + airplay: 'AirPlay', + html5: 'HTML5', + vimeo: 'Vimeo', + youtube: 'YouTube', +}; + const i18n = { get(key = '', config = {}) { if (is.empty(key) || is.empty(config)) { @@ -15,6 +24,10 @@ const i18n = { let string = getDeep(config.i18n, key); if (is.empty(string)) { + if (Object.keys(resources).includes(key)) { + return resources[key]; + } + return ''; } diff --git a/src/js/utils/is.js b/src/js/utils/is.js index 2952d486..ab28f2ab 100644 --- a/src/js/utils/is.js +++ b/src/js/utils/is.js @@ -31,6 +31,11 @@ const isUrl = input => { return true; } + // Must be string from here + if (!isString(input)) { + return false; + } + // Add the protocol if required let string = input; if (!input.startsWith('http://') || !input.startsWith('https://')) { |