aboutsummaryrefslogtreecommitdiffstats
path: root/src/js
diff options
context:
space:
mode:
Diffstat (limited to 'src/js')
-rw-r--r--src/js/config/defaults.js6
-rw-r--r--src/js/config/types.js2
-rw-r--r--src/js/controls.js146
-rw-r--r--src/js/listeners.js52
-rw-r--r--src/js/plugins/vimeo.js3
-rw-r--r--src/js/plyr.js12
-rw-r--r--src/js/ui.js7
-rw-r--r--src/js/utils/i18n.js13
-rw-r--r--src/js/utils/is.js5
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://')) {