aboutsummaryrefslogtreecommitdiffstats
path: root/src/js
diff options
context:
space:
mode:
Diffstat (limited to 'src/js')
-rw-r--r--src/js/config/defaults.js3
-rw-r--r--src/js/controls.js595
-rw-r--r--src/js/listeners.js766
-rw-r--r--src/js/plyr.js113
-rw-r--r--src/js/support.js20
-rw-r--r--src/js/ui.js82
-rw-r--r--src/js/utils/animation.js14
-rw-r--r--src/js/utils/elements.js69
-rw-r--r--src/js/utils/events.js81
9 files changed, 1152 insertions, 591 deletions
diff --git a/src/js/config/defaults.js b/src/js/config/defaults.js
index 1e90a4f0..a898755d 100644
--- a/src/js/config/defaults.js
+++ b/src/js/config/defaults.js
@@ -354,6 +354,9 @@ const defaults = {
isTouch: 'plyr--is-touch',
uiSupported: 'plyr--full-ui',
noTransition: 'plyr--no-transition',
+ display: {
+ time: 'plyr__time',
+ },
menu: {
value: 'plyr__menu__value',
badge: 'plyr__badge',
diff --git a/src/js/controls.js b/src/js/controls.js
index e95cfc86..5c1446d3 100644
--- a/src/js/controls.js
+++ b/src/js/controls.js
@@ -1,5 +1,6 @@
// ==========================================================================
// Plyr controls
+// TODO: This needs to be split into smaller files and cleaned up
// ==========================================================================
import captions from './captions';
@@ -9,19 +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,
- 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 is from './utils/is';
import loadSprite from './utils/loadSprite';
@@ -360,7 +349,7 @@ const controls = {
const container = createElement(
'div',
extend(attributes, {
- class: `plyr__time ${attributes.class}`,
+ class: `${this.config.classNames.display.time} ${attributes.class ? attributes.class : ''}`.trim(),
'aria-label': i18n.get(type, this.config),
}),
'00:00',
@@ -372,37 +361,131 @@ const controls = {
return container;
},
+ // Bind keyboard shortcuts for a menu item
+ bindMenuItemShortcuts(menuItem, type) {
+ // Handle space or -> to open menu
+ on(menuItem, 'keydown', event => {
+ // We only care about space and ⬆️ ⬇️️ ➡️
+ if (![32,38,39,40].includes(event.which)) {
+ return;
+ }
+
+ // Prevent play / seek
+ event.preventDefault();
+ event.stopPropagation();
+
+ const isRadioButton = matches(menuItem, '[role="menuitemradio"]');
+
+ // Show the respective menu
+ if (!isRadioButton && [32,39].includes(event.which)) {
+ controls.showMenuPanel.call(this, type, true);
+ } else {
+ let target;
+
+ if (event.which !== 32) {
+ if (event.which === 40 || isRadioButton && event.which === 39) {
+ target = menuItem.nextElementSibling;
+
+ if (!is.element(target)) {
+ target = menuItem.parentNode.firstElementChild;
+ }
+ } else {
+ target = menuItem.previousElementSibling;
+
+ if (!is.element(target)) {
+ target = menuItem.parentNode.lastElementChild;
+ }
+ }
+
+ setFocus.call(this, target, true);
+ }
+ }
+ }, false);
+ },
+
// Create a settings menu item
createMenuItem({ value, list, type, title, badge = null, checked = false }) {
- const item = createElement('li');
-
- const label = createElement('label', {
- class: this.config.classNames.control,
- });
+ const attributes = getAttributesFromSelector(this.config.selectors.inputs[type]);
- const radio = createElement(
- 'input',
- extend(getAttributesFromSelector(this.config.selectors.inputs[type]), {
- type: 'radio',
- name: `plyr-${type}`,
+ const menuItem = createElement(
+ 'button',
+ extend(attributes, {
+ type: 'button',
+ role: 'menuitemradio',
+ class: `${this.config.classNames.control} ${attributes.class ? attributes.class : ''}`.trim(),
+ 'aria-checked': checked,
value,
- checked,
- class: 'plyr__sr-only',
}),
);
- const faux = createElement('span', { hidden: '' });
+ const flex = createElement('span');
- label.appendChild(radio);
- label.appendChild(faux);
- label.insertAdjacentHTML('beforeend', title);
+ // We have to set as HTML incase of special characters
+ flex.innerHTML = title;
if (is.element(badge)) {
- label.appendChild(badge);
+ flex.appendChild(badge);
}
- item.appendChild(label);
- list.appendChild(item);
+ menuItem.appendChild(flex);
+
+ // Replicate radio button behaviour
+ Object.defineProperty(menuItem, 'checked', {
+ enumerable: true,
+ get() {
+ return menuItem.getAttribute('aria-checked') === 'true';
+ },
+ set(checked) {
+ // Ensure exclusivity
+ if (checked) {
+ Array.from(menuItem.parentNode.children)
+ .filter(node => matches(node, '[role="menuitemradio"]'))
+ .forEach(node => node.setAttribute('aria-checked', 'false'));
+ }
+
+ menuItem.setAttribute('aria-checked', checked ? 'true' : 'false');
+ },
+ });
+
+ this.listeners.bind(
+ menuItem,
+ 'click keydown',
+ event => {
+ if (event.type === 'keydown' && event.which !== 32) {
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ menuItem.checked = true;
+
+ switch (type) {
+ case 'language':
+ this.currentTrack = Number(value);
+ break;
+
+ case 'quality':
+ this.quality = value;
+ break;
+
+ case 'speed':
+ this.speed = parseFloat(value);
+ break;
+
+ default:
+ break;
+ }
+
+ controls.showMenuPanel.call(this, 'home', event.type === 'keydown');
+ },
+ type,
+ false,
+ );
+
+ controls.bindMenuItemShortcuts.call(this, menuItem, type);
+
+ list.appendChild(menuItem);
},
// Format a time for display
@@ -666,19 +749,97 @@ const controls = {
},
// Hide/show a tab
- toggleTab(setting, toggle) {
- toggleHidden(this.elements.settings.tabs[setting], !toggle);
+ toggleMenuButton(setting, toggle) {
+ toggleHidden(this.elements.settings.buttons[setting], !toggle);
+ },
+
+ // Update the selected setting
+ updateSetting(setting, container, input) {
+ const pane = this.elements.settings.panels[setting];
+ let value = null;
+ let list = container;
+
+ if (setting === 'captions') {
+ value = this.currentTrack;
+ } else {
+ value = !is.empty(input) ? input : this[setting];
+
+ // Get default
+ if (is.empty(value)) {
+ value = this.config[setting].default;
+ }
+
+ // Unsupported value
+ if (!is.empty(this.options[setting]) && !this.options[setting].includes(value)) {
+ this.debug.warn(`Unsupported value of '${value}' for ${setting}`);
+ return;
+ }
+
+ // Disabled value
+ if (!this.config[setting].options.includes(value)) {
+ this.debug.warn(`Disabled value of '${value}' for ${setting}`);
+ return;
+ }
+ }
+
+ // Get the list if we need to
+ if (!is.element(list)) {
+ list = pane && pane.querySelector('[role="menu"]');
+ }
+
+ // If there's no list it means it's not been rendered...
+ if (!is.element(list)) {
+ return;
+ }
+
+ // Update the label
+ const label = this.elements.settings.buttons[setting].querySelector(`.${this.config.classNames.menu.value}`);
+ label.innerHTML = controls.getLabel.call(this, setting, value);
+
+ // Find the radio option and check it
+ const target = list && list.querySelector(`[value="${value}"]`);
+
+ if (is.element(target)) {
+ target.checked = true;
+ }
+ },
+
+ // Translate a value into a nice label
+ getLabel(setting, value) {
+ switch (setting) {
+ case 'speed':
+ return value === 1 ? i18n.get('normal', this.config) : `${value}×`;
+
+ case 'quality':
+ if (is.number(value)) {
+ const label = i18n.get(`qualityLabel.${value}`, this.config);
+
+ if (!label.length) {
+ return `${value}p`;
+ }
+
+ return label;
+ }
+
+ return toTitleCase(value);
+
+ case 'captions':
+ return captions.getLabel.call(this);
+
+ default:
+ return null;
+ }
},
// Set the quality menu
setQualityMenu(options) {
// Menu required
- if (!is.element(this.elements.settings.panes.quality)) {
+ if (!is.element(this.elements.settings.panels.quality)) {
return;
}
const type = 'quality';
- const list = this.elements.settings.panes.quality.querySelector('ul');
+ const list = this.elements.settings.panels.quality.querySelector('[role="menu"]');
// Set options if passed and filter based on uniqueness and config
if (is.array(options)) {
@@ -687,7 +848,10 @@ const controls = {
// Toggle the pane and tab
const toggle = !is.empty(this.options.quality) && this.options.quality.length > 1;
- controls.toggleTab.call(this, type, toggle);
+ controls.toggleMenuButton.call(this, type, toggle);
+
+ // Empty the menu
+ emptyElement(list);
// Check if we need to toggle the parent
controls.checkMenu.call(this);
@@ -697,9 +861,6 @@ const controls = {
return;
}
- // Empty the menu
- emptyElement(list);
-
// Get the badge HTML for HD, 4K etc
const getBadge = quality => {
const label = i18n.get(`qualityBadge.${quality}`, this.config);
@@ -730,101 +891,23 @@ const controls = {
controls.updateSetting.call(this, type, list);
},
- // Translate a value into a nice label
- getLabel(setting, value) {
- switch (setting) {
- case 'speed':
- return value === 1 ? i18n.get('normal', this.config) : `${value}×`;
-
- case 'quality':
- if (is.number(value)) {
- const label = i18n.get(`qualityLabel.${value}`, this.config);
-
- if (!label.length) {
- return `${value}p`;
- }
-
- return label;
- }
-
- return toTitleCase(value);
-
- case 'captions':
- return captions.getLabel.call(this);
-
- default:
- return null;
- }
- },
-
- // Update the selected setting
- updateSetting(setting, container, input) {
- const pane = this.elements.settings.panes[setting];
- let value = null;
- let list = container;
-
- if (setting === 'captions') {
- value = this.currentTrack;
- } else {
- value = !is.empty(input) ? input : this[setting];
-
- // Get default
- if (is.empty(value)) {
- value = this.config[setting].default;
- }
-
- // Unsupported value
- if (!is.empty(this.options[setting]) && !this.options[setting].includes(value)) {
- this.debug.warn(`Unsupported value of '${value}' for ${setting}`);
- return;
- }
-
- // Disabled value
- if (!this.config[setting].options.includes(value)) {
- this.debug.warn(`Disabled value of '${value}' for ${setting}`);
- return;
- }
- }
-
- // Get the list if we need to
- if (!is.element(list)) {
- list = pane && pane.querySelector('ul');
- }
-
- // If there's no list it means it's not been rendered...
- if (!is.element(list)) {
- return;
- }
-
- // Update the label
- const label = this.elements.settings.tabs[setting].querySelector(`.${this.config.classNames.menu.value}`);
- label.innerHTML = controls.getLabel.call(this, setting, value);
-
- // Find the radio option and check it
- const target = list && list.querySelector(`input[value="${value}"]`);
-
- if (is.element(target)) {
- target.checked = true;
- }
- },
-
// Set the looping options
/* setLoopMenu() {
// Menu required
- if (!is.element(this.elements.settings.panes.loop)) {
+ if (!is.element(this.elements.settings.panels.loop)) {
return;
}
const options = ['start', 'end', 'all', 'reset'];
- const list = this.elements.settings.panes.loop.querySelector('ul');
+ const list = this.elements.settings.panels.loop.querySelector('[role="menu"]');
// Show the pane and tab
- toggleHidden(this.elements.settings.tabs.loop, false);
- toggleHidden(this.elements.settings.panes.loop, false);
+ toggleHidden(this.elements.settings.buttons.loop, false);
+ toggleHidden(this.elements.settings.panels.loop, false);
// Toggle the pane and tab
const toggle = !is.empty(this.loop.options);
- controls.toggleTab.call(this, 'loop', toggle);
+ controls.toggleMenuButton.call(this, 'loop', toggle);
// Empty the menu
emptyElement(list);
@@ -857,13 +940,19 @@ const controls = {
// Set a list of available captions languages
setCaptionsMenu() {
+ // Menu required
+ if (!is.element(this.elements.settings.panels.captions)) {
+ return;
+ }
+
// TODO: Captions or language? Currently it's mixed
const type = 'captions';
- const list = this.elements.settings.panes.captions.querySelector('ul');
+ const list = this.elements.settings.panels.captions.querySelector('[role="menu"]');
const tracks = captions.getTracks.call(this);
+ const toggle = Boolean(tracks.length);
// Toggle the pane and tab
- controls.toggleTab.call(this, type, tracks.length);
+ controls.toggleMenuButton.call(this, type, toggle);
// Empty the menu
emptyElement(list);
@@ -872,7 +961,7 @@ const controls = {
controls.checkMenu.call(this);
// If there's no captions, bail
- if (!tracks.length) {
+ if (!toggle) {
return;
}
@@ -903,17 +992,13 @@ const controls = {
// Set a list of available captions languages
setSpeedMenu(options) {
- // Do nothing if not selected
- if (!this.config.controls.includes('settings') || !this.config.settings.includes('speed')) {
- return;
- }
-
// Menu required
- if (!is.element(this.elements.settings.panes.speed)) {
+ if (!is.element(this.elements.settings.panels.speed)) {
return;
}
const type = 'speed';
+ const list = this.elements.settings.panels.speed.querySelector('[role="menu"]');
// Set the speed options
if (is.array(options)) {
@@ -927,7 +1012,10 @@ const controls = {
// Toggle the pane and tab
const toggle = !is.empty(this.options.speed) && this.options.speed.length > 1;
- controls.toggleTab.call(this, type, toggle);
+ controls.toggleMenuButton.call(this, type, toggle);
+
+ // Empty the menu
+ emptyElement(list);
// Check if we need to toggle the parent
controls.checkMenu.call(this);
@@ -937,12 +1025,6 @@ const controls = {
return;
}
- // Get the list to populate
- const list = this.elements.settings.panes.speed.querySelector('ul');
-
- // Empty the menu
- emptyElement(list);
-
// Create items
this.options.speed.forEach(speed => {
controls.createMenuItem.call(this, {
@@ -958,27 +1040,27 @@ const controls = {
// Check if we need to hide/show the settings menu
checkMenu() {
- const { tabs } = this.elements.settings;
- const visible = !is.empty(tabs) && Object.values(tabs).some(tab => !tab.hidden);
+ const { buttons } = this.elements.settings;
+ const visible = !is.empty(buttons) && Object.values(buttons).some(button => !button.hidden);
toggleHidden(this.elements.settings.menu, !visible);
},
// Show/hide menu
- toggleMenu(event) {
- const { form } = this.elements.settings;
+ toggleMenu(input) {
+ const { popup } = this.elements.settings;
const button = this.elements.buttons.settings;
// Menu and button are required
- if (!is.element(form) || !is.element(button)) {
+ if (!is.element(popup) || !is.element(button)) {
return;
}
- const show = is.boolean(event) ? event : is.element(form) && form.hasAttribute('hidden');
+ const show = is.boolean(input) ? input : is.element(popup) && popup.hasAttribute('hidden');
- if (is.event(event)) {
- const isMenuItem = is.element(form) && form.contains(event.target);
- const isButton = event.target === this.elements.buttons.settings;
+ if (is.event(input)) {
+ const isMenuItem = is.element(popup) && popup.contains(input.target);
+ const isButton = input.target === this.elements.buttons.settings;
// If the click was inside the form or if the click
// wasn't the button or menu item and we're trying to
@@ -989,7 +1071,7 @@ const controls = {
// Prevent the toggle being caught by the doc listener
if (isButton) {
- event.stopPropagation();
+ input.stopPropagation();
}
}
@@ -998,31 +1080,27 @@ const controls = {
button.setAttribute('aria-expanded', show);
}
- if (is.element(form)) {
- toggleHidden(form, !show);
+ // Show the actual popup
+ if (is.element(popup)) {
+ toggleHidden(popup, !show);
toggleClass(this.elements.container, this.config.classNames.menu.open, show);
- if (show) {
- form.removeAttribute('tabindex');
- } else {
- form.setAttribute('tabindex', -1);
+ // Focus the first item if key interaction
+ if (show && is.event(input) && input.type === 'keydown') {
+ const pane = Object.values(this.elements.settings.panels).find(pane => !pane.hidden);
+ const firstItem = pane.querySelector('[role^="menuitem"]');
+ setFocus.call(this, firstItem, true);
}
}
},
- // Get the natural size of a tab
- getTabSize(tab) {
+ // Get the natural size of a menu panel
+ getMenuSize(tab) {
const clone = tab.cloneNode(true);
clone.style.position = 'absolute';
clone.style.opacity = 0;
clone.removeAttribute('hidden');
- // Prevent input's being unchecked due to the name being identical
- Array.from(clone.querySelectorAll('input[name]')).forEach(input => {
- const name = input.getAttribute('name');
- input.setAttribute('name', `${name}-clone`);
- });
-
// Append to parent so we get the "real" size
tab.parentNode.appendChild(clone);
@@ -1039,31 +1117,18 @@ const controls = {
};
},
- // Toggle Menu
- showTab(target = '') {
- const { menu } = this.elements.settings;
- const pane = document.getElementById(target);
+ // Show a panel in the menu
+ showMenuPanel(type = '', tabFocus = false) {
+ const target = document.getElementById(`plyr-settings-${this.id}-${type}`);
// Nothing to show, bail
- if (!is.element(pane)) {
- return;
- }
-
- // Are we targeting a tab? If not, bail
- const isTab = pane.getAttribute('role') === 'tabpanel';
- if (!isTab) {
+ if (!is.element(target)) {
return;
}
- // Hide all other tabs
- // Get other tabs
- const current = menu.querySelector('[role="tabpanel"]:not([hidden])');
- const container = current.parentNode;
-
- // Set other toggles to be expanded false
- Array.from(menu.querySelectorAll(`[aria-controls="${current.getAttribute('id')}"]`)).forEach(toggle => {
- toggle.setAttribute('aria-expanded', false);
- });
+ // Hide all other panels
+ const container = target.parentNode;
+ const current = Array.from(container.children).find(node => !node.hidden);
// If we can do fancy animations, we'll animate the height/width
if (support.transitions && !support.reducedMotion) {
@@ -1072,12 +1137,12 @@ const controls = {
container.style.height = `${current.scrollHeight}px`;
// Get potential sizes
- const size = controls.getTabSize.call(this, pane);
+ const size = controls.getMenuSize.call(this, target);
// Restore auto height/width
- const restore = e => {
+ const restore = event => {
// We're only bothered about height and width on the container
- if (e.target !== container || !['width', 'height'].includes(e.propertyName)) {
+ if (event.target !== container || !['width', 'height'].includes(event.propertyName)) {
return;
}
@@ -1099,19 +1164,13 @@ const controls = {
// Set attributes on current tab
toggleHidden(current, true);
- current.setAttribute('tabindex', -1);
// Set attributes on target
- toggleHidden(pane, false);
-
- const tabs = getElements.call(this, `[aria-controls="${target}"]`);
- Array.from(tabs).forEach(tab => {
- tab.setAttribute('aria-expanded', true);
- });
- pane.removeAttribute('tabindex');
+ toggleHidden(target, false);
// Focus the first item
- pane.querySelectorAll('button:not(:disabled), input:not(:disabled), [tabindex]')[0].focus();
+ const firstItem = target.querySelector('[role^="menuitem"]');
+ setFocus.call(this, firstItem, tabFocus);
},
// Build the default HTML
@@ -1230,12 +1289,12 @@ const controls = {
// Settings button / menu
if (this.config.controls.includes('settings') && !is.empty(this.config.settings)) {
- const menu = createElement('div', {
+ const control = createElement('div', {
class: 'plyr__menu',
hidden: '',
});
- menu.appendChild(
+ control.appendChild(
controls.createButton.call(this, 'settings', {
id: `plyr-settings-toggle-${data.id}`,
'aria-haspopup': true,
@@ -1244,48 +1303,52 @@ const controls = {
}),
);
- const form = createElement('form', {
+ const popup = createElement('div', {
class: 'plyr__menu__container',
id: `plyr-settings-${data.id}`,
hidden: '',
'aria-labelled-by': `plyr-settings-toggle-${data.id}`,
- role: 'tablist',
- tabindex: -1,
});
const inner = createElement('div');
const home = createElement('div', {
id: `plyr-settings-${data.id}-home`,
- 'aria-labelled-by': `plyr-settings-toggle-${data.id}`,
- role: 'tabpanel',
});
- // Create the tab list
- const tabs = createElement('ul', {
- role: 'tablist',
+ // Create the menu
+ const menu = createElement('div', {
+ role: 'menu',
});
- // Build the tabs
- this.config.settings.forEach(type => {
- const tab = createElement('li', {
- role: 'tab',
- hidden: '',
- });
+ home.appendChild(menu);
+ inner.appendChild(home);
+ this.elements.settings.panels.home = home;
- const button = createElement(
+ // 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`,
- id: `plyr-settings-${data.id}-${type}-tab`,
+ role: 'menuitem',
'aria-haspopup': true,
- 'aria-controls': `plyr-settings-${data.id}-${type}`,
- 'aria-expanded': false,
+ hidden: '',
}),
- i18n.get(type, this.config),
);
+ // Bind menu shortcuts for keyboard users
+ controls.bindMenuItemShortcuts.call(this, menuItem, type);
+
+ // Show menu on click
+ on(menuItem, 'click', () => {
+ controls.showMenuPanel.call(this, type, false);
+ });
+
+ const flex = createElement('span', null, i18n.get(type, this.config));
+
const value = createElement('span', {
class: this.config.classNames.menu.value,
});
@@ -1293,54 +1356,86 @@ const controls = {
// Speed contains HTML entities
value.innerHTML = data[type];
- button.appendChild(value);
- tab.appendChild(button);
- tabs.appendChild(tab);
-
- this.elements.settings.tabs[type] = tab;
- });
-
- home.appendChild(tabs);
- inner.appendChild(home);
+ flex.appendChild(value);
+ menuItem.appendChild(flex);
+ menu.appendChild(menuItem);
- // Build the panes
- this.config.settings.forEach(type => {
+ // Build the panes
const pane = createElement('div', {
id: `plyr-settings-${data.id}-${type}`,
hidden: '',
- 'aria-labelled-by': `plyr-settings-${data.id}-${type}-tab`,
- role: 'tabpanel',
- tabindex: -1,
});
- const back = createElement(
- 'button',
- {
- type: 'button',
- class: `${this.config.classNames.control} ${this.config.classNames.control}--back`,
- 'aria-haspopup': true,
- 'aria-controls': `plyr-settings-${data.id}-home`,
- 'aria-expanded': false,
- },
- i18n.get(type, this.config),
+ // 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),
+ ),
);
- pane.appendChild(back);
+ // Go back via keyboard
+ on(pane, 'keydown', event => {
+ // We only care about <-
+ if (event.which !== 37) {
+ return;
+ }
+
+ // Prevent seek
+ event.preventDefault();
+ event.stopPropagation();
+
+ // Show the respective menu
+ controls.showMenuPanel.call(this, 'home', true);
+ }, false);
+
+ // Go back via button click
+ on(backButton, 'click', () => {
+ controls.showMenuPanel.call(this, 'home', false);
+ });
+
+ // Add to pane
+ pane.appendChild(backButton);
- const options = createElement('ul');
+ // Menu
+ pane.appendChild(
+ createElement('div', {
+ role: 'menu',
+ }),
+ );
- pane.appendChild(options);
inner.appendChild(pane);
- this.elements.settings.panes[type] = pane;
+ this.elements.settings.buttons[type] = menuItem;
+ this.elements.settings.panels[type] = pane;
});
- form.appendChild(inner);
- menu.appendChild(form);
- container.appendChild(menu);
+ popup.appendChild(inner);
+ control.appendChild(popup);
+ container.appendChild(control);
- this.elements.settings.form = form;
- this.elements.settings.menu = menu;
+ this.elements.settings.popup = popup;
+ this.elements.settings.menu = control;
}
// Picture in picture button
diff --git a/src/js/listeners.js b/src/js/listeners.js
index 9583bd71..e19894ba 100644
--- a/src/js/listeners.js
+++ b/src/js/listeners.js
@@ -4,8 +4,16 @@
import controls from './controls';
import ui from './ui';
+import { repaint } from './utils/animation';
import browser from './utils/browser';
-import { getElement, getElements, getFocusElement, matches, toggleClass, toggleHidden } from './utils/elements';
+import {
+ getElement,
+ getElements,
+ hasClass,
+ matches,
+ toggleClass,
+ toggleHidden,
+} from './utils/elements';
import { on, once, toggleListener, triggerEvent } from './utils/events';
import is from './utils/is';
@@ -13,14 +21,18 @@ class Listeners {
constructor(player) {
this.player = player;
this.lastKey = null;
+ this.focusTimer = null;
+ this.lastKeyDown = null;
this.handleKey = this.handleKey.bind(this);
this.toggleMenu = this.toggleMenu.bind(this);
+ this.setTabFocus = this.setTabFocus.bind(this);
this.firstTouch = this.firstTouch.bind(this);
}
// Handle key presses
handleKey(event) {
+ const { player } = this;
const code = event.keyCode ? event.keyCode : event.which;
const pressed = event.type === 'keydown';
const repeat = pressed && code === this.lastKey;
@@ -39,27 +51,57 @@ class Listeners {
// Seek by the number keys
const seekByKey = () => {
// Divide the max duration into 10th's and times by the number value
- this.player.currentTime = this.player.duration / 10 * (code - 48);
+ player.currentTime = player.duration / 10 * (code - 48);
};
// Handle the key on keydown
// Reset on keyup
if (pressed) {
- // Which keycodes should we prevent default
- const preventDefault = [32, 37, 38, 39, 40, 48, 49, 50, 51, 52, 53, 54, 56, 57, 67, 70, 73, 75, 76, 77, 79];
-
// Check focused element
// and if the focused element is not editable (e.g. text input)
// and any that accept key input http://webaim.org/techniques/keyboard/
- const focused = getFocusElement();
- if (
- is.element(focused) &&
- (focused !== this.player.elements.inputs.seek &&
- matches(focused, this.player.config.selectors.editable))
- ) {
- return;
+ const focused = document.activeElement;
+ if (is.element(focused)) {
+ const { editable } = player.config.selectors;
+ const { seek } = player.elements.inputs;
+
+ if (focused !== seek && matches(focused, editable)) {
+ return;
+ }
+
+ if (
+ event.which === 32 &&
+ matches(focused, 'button, [role^="menuitem"]')
+ ) {
+ return;
+ }
}
+ // Which keycodes should we prevent default
+ const preventDefault = [
+ 32,
+ 37,
+ 38,
+ 39,
+ 40,
+ 48,
+ 49,
+ 50,
+ 51,
+ 52,
+ 53,
+ 54,
+ 56,
+ 57,
+ 67,
+ 70,
+ 73,
+ 75,
+ 76,
+ 77,
+ 79,
+ ];
+
// If the code is found prevent default (e.g. prevent scrolling for arrows)
if (preventDefault.includes(code)) {
event.preventDefault();
@@ -87,52 +129,52 @@ class Listeners {
case 75:
// Space and K key
if (!repeat) {
- this.player.togglePlay();
+ player.togglePlay();
}
break;
case 38:
// Arrow up
- this.player.increaseVolume(0.1);
+ player.increaseVolume(0.1);
break;
case 40:
// Arrow down
- this.player.decreaseVolume(0.1);
+ player.decreaseVolume(0.1);
break;
case 77:
// M key
if (!repeat) {
- this.player.muted = !this.player.muted;
+ player.muted = !player.muted;
}
break;
case 39:
// Arrow forward
- this.player.forward();
+ player.forward();
break;
case 37:
// Arrow back
- this.player.rewind();
+ player.rewind();
break;
case 70:
// F key
- this.player.fullscreen.toggle();
+ player.fullscreen.toggle();
break;
case 67:
// C key
if (!repeat) {
- this.player.toggleCaptions();
+ player.toggleCaptions();
}
break;
case 76:
// L key
- this.player.loop = !this.player.loop;
+ player.loop = !player.loop;
break;
/* case 73:
@@ -153,8 +195,12 @@ class Listeners {
// Escape is handle natively when in full screen
// So we only need to worry about non native
- if (!this.player.fullscreen.enabled && this.player.fullscreen.active && code === 27) {
- this.player.fullscreen.toggle();
+ if (
+ !player.fullscreen.enabled &&
+ player.fullscreen.active &&
+ code === 27
+ ) {
+ player.fullscreen.toggle();
}
// Store last code for next cycle
@@ -171,58 +217,131 @@ class Listeners {
// Device is touch enabled
firstTouch() {
- this.player.touch = true;
+ const { player } = this;
+
+ player.touch = true;
// Add touch class
- toggleClass(this.player.elements.container, this.player.config.classNames.isTouch, true);
+ toggleClass(
+ player.elements.container,
+ player.config.classNames.isTouch,
+ true,
+ );
+ }
+
+ setTabFocus(event) {
+ const { player } = this;
+
+ clearTimeout(this.focusTimer);
+
+ // Ignore any key other than tab
+ if (event.type === 'keydown' && event.code !== 'Tab') {
+ return;
+ }
+
+ // Store reference to event timeStamp
+ if (event.type === 'keydown') {
+ this.lastKeyDown = event.timeStamp;
+ }
+
+ // Remove current classes
+ const removeCurrent = () => {
+ const className = player.config.classNames.tabFocus;
+ const current = getElements.call(player, `.${className}`);
+ toggleClass(current, className, false);
+ };
+
+ // Determine if a key was pressed to trigger this event
+ const wasKeyDown = event.timeStamp - this.lastKeyDown <= 20;
+
+ // Ignore focus events if a key was pressed prior
+ if (event.type === 'focus' && !wasKeyDown) {
+ return;
+ }
+
+ // Remove all current
+ removeCurrent();
+
+ // Delay the adding of classname until the focus has changed
+ // This event fires before the focusin event
+ this.focusTimer = setTimeout(() => {
+ const focused = document.activeElement;
+
+ // Ignore if current focus element isn't inside the player
+ if (!player.elements.container.contains(focused)) {
+ return;
+ }
+
+ toggleClass(
+ document.activeElement,
+ player.config.classNames.tabFocus,
+ true,
+ );
+ }, 10);
}
// Global window & document listeners
global(toggle = true) {
+ const { player } = this;
+
// Keyboard shortcuts
- if (this.player.config.keyboard.global) {
- toggleListener.call(this.player, window, 'keydown keyup', this.handleKey, toggle, false);
+ if (player.config.keyboard.global) {
+ toggleListener.call(
+ player,
+ window,
+ 'keydown keyup',
+ this.handleKey,
+ toggle,
+ false,
+ );
}
// Click anywhere closes menu
- toggleListener.call(this.player, document.body, 'click', this.toggleMenu, toggle);
+ toggleListener.call(
+ player,
+ document.body,
+ 'click',
+ this.toggleMenu,
+ toggle,
+ );
// Detect touch by events
- once.call(this.player, document.body, 'touchstart', this.firstTouch);
+ once.call(player, document.body, 'touchstart', this.firstTouch);
+
+ // Tab focus detection
+ toggleListener.call(
+ player,
+ document.body,
+ 'keydown focus blur',
+ this.setTabFocus,
+ toggle,
+ false,
+ true,
+ );
}
// Container listeners
container() {
+ const { player } = this;
+
// Keyboard shortcuts
- if (!this.player.config.keyboard.global && this.player.config.keyboard.focused) {
- on.call(this.player, this.player.elements.container, 'keydown keyup', this.handleKey, false);
+ if (!player.config.keyboard.global && player.config.keyboard.focused) {
+ on.call(
+ player,
+ player.elements.container,
+ 'keydown keyup',
+ this.handleKey,
+ false,
+ );
}
- // Detect tab focus
- // Remove class on blur/focusout
- on.call(this.player, this.player.elements.container, 'focusout', event => {
- toggleClass(event.target, this.player.config.classNames.tabFocus, false);
- });
- // Add classname to tabbed elements
- on.call(this.player, this.player.elements.container, 'keydown', event => {
- if (event.keyCode !== 9) {
- return;
- }
-
- // Delay the adding of classname until the focus has changed
- // This event fires before the focusin event
- setTimeout(() => {
- toggleClass(getFocusElement(), this.player.config.classNames.tabFocus, true);
- }, 0);
- });
-
// Toggle controls on mouse events and entering fullscreen
on.call(
- this.player,
- this.player.elements.container,
+ player,
+ player.elements.container,
'mousemove mouseleave touchstart touchmove enterfullscreen exitfullscreen',
event => {
- const { controls } = this.player.elements;
+ const { controls } = player.elements;
// Remove button states for fullscreen
if (event.type === 'enterfullscreen') {
@@ -231,90 +350,116 @@ class Listeners {
}
// Show, then hide after a timeout unless another control event occurs
- const show = ['touchstart', 'touchmove', 'mousemove'].includes(event.type);
+ const show = ['touchstart', 'touchmove', 'mousemove'].includes(
+ event.type,
+ );
let delay = 0;
if (show) {
- ui.toggleControls.call(this.player, true);
+ ui.toggleControls.call(player, true);
// Use longer timeout for touch devices
- delay = this.player.touch ? 3000 : 2000;
+ delay = player.touch ? 3000 : 2000;
}
// Clear timer
- clearTimeout(this.player.timers.controls);
- // Timer to prevent flicker when seeking
- this.player.timers.controls = setTimeout(() => ui.toggleControls.call(this.player, false), delay);
+ clearTimeout(player.timers.controls);
+
+ // Set new timer to prevent flicker when seeking
+ player.timers.controls = setTimeout(
+ () => ui.toggleControls.call(player, false),
+ delay,
+ );
},
);
}
// Listen for media events
media() {
+ const { player } = this;
+
// Time change on media
- on.call(this.player, this.player.media, 'timeupdate seeking seeked', event =>
- controls.timeUpdate.call(this.player, event),
+ on.call(player, player.media, 'timeupdate seeking seeked', event =>
+ controls.timeUpdate.call(player, event),
);
// Display duration
- on.call(this.player, this.player.media, 'durationchange loadeddata loadedmetadata', event =>
- controls.durationUpdate.call(this.player, event),
+ on.call(
+ player,
+ player.media,
+ 'durationchange loadeddata loadedmetadata',
+ event => controls.durationUpdate.call(player, event),
);
// Check for audio tracks on load
// We can't use `loadedmetadata` as it doesn't seem to have audio tracks at that point
- on.call(this.player, this.player.media, 'canplay', () => {
- toggleHidden(this.player.elements.volume, !this.player.hasAudio);
- toggleHidden(this.player.elements.buttons.mute, !this.player.hasAudio);
+ on.call(player, player.media, 'canplay', () => {
+ toggleHidden(player.elements.volume, !player.hasAudio);
+ toggleHidden(player.elements.buttons.mute, !player.hasAudio);
});
// Handle the media finishing
- on.call(this.player, this.player.media, 'ended', () => {
+ on.call(player, player.media, 'ended', () => {
// Show poster on end
- if (this.player.isHTML5 && this.player.isVideo && this.player.config.resetOnEnd) {
+ if (player.isHTML5 && player.isVideo && player.config.resetOnEnd) {
// Restart
- this.player.restart();
+ player.restart();
}
});
// Check for buffer progress
- on.call(this.player, this.player.media, 'progress playing seeking seeked', event =>
- controls.updateProgress.call(this.player, event),
+ on.call(
+ player,
+ player.media,
+ 'progress playing seeking seeked',
+ event => controls.updateProgress.call(player, event),
);
// Handle volume changes
- on.call(this.player, this.player.media, 'volumechange', event =>
- controls.updateVolume.call(this.player, event),
+ on.call(player, player.media, 'volumechange', event =>
+ controls.updateVolume.call(player, event),
);
// Handle play/pause
- on.call(this.player, this.player.media, 'playing play pause ended emptied timeupdate', event =>
- ui.checkPlaying.call(this.player, event),
+ on.call(
+ player,
+ player.media,
+ 'playing play pause ended emptied timeupdate',
+ event => ui.checkPlaying.call(player, event),
);
// Loading state
- on.call(this.player, this.player.media, 'waiting canplay seeked playing', event =>
- ui.checkLoading.call(this.player, event),
+ on.call(player, player.media, 'waiting canplay seeked playing', event =>
+ ui.checkLoading.call(player, event),
);
// If autoplay, then load advertisement if required
// TODO: Show some sort of loading state while the ad manager loads else there's a delay before ad shows
- on.call(this.player, this.player.media, 'playing', () => {
- if (!this.player.ads) {
+ on.call(player, player.media, 'playing', () => {
+ if (!player.ads) {
return;
}
// If ads are enabled, wait for them first
- if (this.player.ads.enabled && !this.player.ads.initialized) {
+ if (player.ads.enabled && !player.ads.initialized) {
// Wait for manager response
- this.player.ads.managerPromise.then(() => this.player.ads.play()).catch(() => this.player.play());
+ player.ads.managerPromise
+ .then(() => player.ads.play())
+ .catch(() => player.play());
}
});
// Click video
- if (this.player.supported.ui && this.player.config.clickToPlay && !this.player.isAudio) {
+ if (
+ player.supported.ui &&
+ player.config.clickToPlay &&
+ !player.isAudio
+ ) {
// Re-fetch the wrapper
- const wrapper = getElement.call(this.player, `.${this.player.config.classNames.video}`);
+ const wrapper = getElement.call(
+ player,
+ `.${player.config.classNames.video}`,
+ );
// Bail if there's no wrapper (this should never happen)
if (!is.element(wrapper)) {
@@ -322,28 +467,49 @@ class Listeners {
}
// On click play, pause ore restart
- on.call(this.player, wrapper, 'click', () => {
- // Touch devices will just show controls (if we're hiding controls)
- if (this.player.config.hideControls && this.player.touch && !this.player.paused) {
- return;
- }
+ on.call(
+ player,
+ player.elements.container,
+ 'click touchstart',
+ event => {
+ const targets = [player.elements.container, wrapper];
+
+ // Ignore if click if not container or in video wrapper
+ if (
+ !targets.includes(event.target) &&
+ !wrapper.contains(event.target)
+ ) {
+ return;
+ }
- if (this.player.paused) {
- this.player.play();
- } else if (this.player.ended) {
- this.player.restart();
- this.player.play();
- } else {
- this.player.pause();
- }
- });
+ // First touch on touch devices will just show controls (if we're hiding controls)
+ // If controls are shown then it'll toggle like a pointer device
+ if (
+ player.config.hideControls &&
+ player.touch &&
+ hasClass(
+ player.elements.container,
+ player.config.classNames.hideControls,
+ )
+ ) {
+ return;
+ }
+
+ if (player.ended) {
+ player.restart();
+ player.play();
+ } else {
+ player.togglePlay();
+ }
+ },
+ );
}
// Disable right click
- if (this.player.supported.ui && this.player.config.disableContextMenu) {
+ if (player.supported.ui && player.config.disableContextMenu) {
on.call(
- this.player,
- this.player.elements.wrapper,
+ player,
+ player.elements.wrapper,
'contextmenu',
event => {
event.preventDefault();
@@ -353,220 +519,251 @@ class Listeners {
}
// Volume change
- on.call(this.player, this.player.media, 'volumechange', () => {
+ on.call(player, player.media, 'volumechange', () => {
// Save to storage
- this.player.storage.set({ volume: this.player.volume, muted: this.player.muted });
+ player.storage.set({
+ volume: player.volume,
+ muted: player.muted,
+ });
});
// Speed change
- on.call(this.player, this.player.media, 'ratechange', () => {
+ on.call(player, player.media, 'ratechange', () => {
// Update UI
- controls.updateSetting.call(this.player, 'speed');
+ controls.updateSetting.call(player, 'speed');
// Save to storage
- this.player.storage.set({ speed: this.player.speed });
+ player.storage.set({ speed: player.speed });
});
// Quality request
- on.call(this.player, this.player.media, 'qualityrequested', event => {
+ on.call(player, player.media, 'qualityrequested', event => {
// Save to storage
- this.player.storage.set({ quality: event.detail.quality });
+ player.storage.set({ quality: event.detail.quality });
});
// Quality change
- on.call(this.player, this.player.media, 'qualitychange', event => {
+ on.call(player, player.media, 'qualitychange', event => {
// Update UI
- controls.updateSetting.call(this.player, 'quality', null, event.detail.quality);
+ controls.updateSetting.call(
+ player,
+ 'quality',
+ null,
+ event.detail.quality,
+ );
});
// Proxy events to container
// Bubble up key events for Edge
- const proxyEvents = this.player.config.events.concat(['keyup', 'keydown']).join(' ');
- on.call(this.player, this.player.media, proxyEvents, event => {
+ const proxyEvents = player.config.events
+ .concat(['keyup', 'keydown'])
+ .join(' ');
+
+ on.call(player, player.media, proxyEvents, event => {
let { detail = {} } = event;
// Get error details from media
if (event.type === 'error') {
- detail = this.player.media.error;
+ detail = player.media.error;
}
- triggerEvent.call(this.player, this.player.elements.container, event.type, true, detail);
+ triggerEvent.call(
+ player,
+ player.elements.container,
+ event.type,
+ true,
+ detail,
+ );
});
}
- // Listen for control events
- controls() {
- // IE doesn't support input event, so we fallback to change
- const inputEvent = browser.isIE ? 'change' : 'input';
+ // Run default and custom handlers
+ proxy(event, defaultHandler, customHandlerKey) {
+ const { player } = this;
+ const customHandler = player.config.listeners[customHandlerKey];
+ const hasCustomHandler = is.function(customHandler);
+ let returned = true;
- // Run default and custom handlers
- const proxy = (event, defaultHandler, customHandlerKey) => {
- const customHandler = this.player.config.listeners[customHandlerKey];
- const hasCustomHandler = is.function(customHandler);
- let returned = true;
+ // Execute custom handler
+ if (hasCustomHandler) {
+ returned = customHandler.call(player, event);
+ }
- // Execute custom handler
- if (hasCustomHandler) {
- returned = customHandler.call(this.player, event);
- }
+ // Only call default handler if not prevented in custom handler
+ if (returned && is.function(defaultHandler)) {
+ defaultHandler.call(player, event);
+ }
+ }
- // Only call default handler if not prevented in custom handler
- if (returned && is.function(defaultHandler)) {
- defaultHandler.call(this.player, event);
- }
- };
+ // Trigger custom and default handlers
+ bind(element, type, defaultHandler, customHandlerKey, passive = true) {
+ const { player } = this;
+ const customHandler = player.config.listeners[customHandlerKey];
+ const hasCustomHandler = is.function(customHandler);
- // Trigger custom and default handlers
- const bind = (element, type, defaultHandler, customHandlerKey, passive = true) => {
- const customHandler = this.player.config.listeners[customHandlerKey];
- const hasCustomHandler = is.function(customHandler);
+ on.call(
+ player,
+ element,
+ type,
+ event => this.proxy(event, defaultHandler, customHandlerKey),
+ passive && !hasCustomHandler,
+ );
+ }
- on.call(
- this.player,
- element,
- type,
- event => proxy(event, defaultHandler, customHandlerKey),
- passive && !hasCustomHandler,
- );
- };
+ // Listen for control events
+ controls() {
+ const { player } = this;
+
+ // IE doesn't support input event, so we fallback to change
+ const inputEvent = browser.isIE ? 'change' : 'input';
// Play/pause toggle
- if (this.player.elements.buttons.play) {
- Array.from(this.player.elements.buttons.play).forEach(button => {
- bind(button, 'click', this.player.togglePlay, 'play');
+ if (player.elements.buttons.play) {
+ Array.from(player.elements.buttons.play).forEach(button => {
+ this.bind(button, 'click', player.togglePlay, 'play');
});
}
// Pause
- bind(this.player.elements.buttons.restart, 'click', this.player.restart, 'restart');
+ this.bind(
+ player.elements.buttons.restart,
+ 'click',
+ player.restart,
+ 'restart',
+ );
// Rewind
- bind(this.player.elements.buttons.rewind, 'click', this.player.rewind, 'rewind');
+ this.bind(
+ player.elements.buttons.rewind,
+ 'click',
+ player.rewind,
+ 'rewind',
+ );
// Rewind
- bind(this.player.elements.buttons.fastForward, 'click', this.player.forward, 'fastForward');
+ this.bind(
+ player.elements.buttons.fastForward,
+ 'click',
+ player.forward,
+ 'fastForward',
+ );
// Mute toggle
- bind(
- this.player.elements.buttons.mute,
+ this.bind(
+ player.elements.buttons.mute,
'click',
() => {
- this.player.muted = !this.player.muted;
+ player.muted = !player.muted;
},
'mute',
);
// Captions toggle
- bind(this.player.elements.buttons.captions, 'click', () => this.player.toggleCaptions());
+ this.bind(player.elements.buttons.captions, 'click', () =>
+ player.toggleCaptions(),
+ );
// Fullscreen toggle
- bind(
- this.player.elements.buttons.fullscreen,
+ this.bind(
+ player.elements.buttons.fullscreen,
'click',
() => {
- this.player.fullscreen.toggle();
+ player.fullscreen.toggle();
},
'fullscreen',
);
// Picture-in-Picture
- bind(
- this.player.elements.buttons.pip,
+ this.bind(
+ player.elements.buttons.pip,
'click',
() => {
- this.player.pip = 'toggle';
+ player.pip = 'toggle';
},
'pip',
);
// Airplay
- bind(this.player.elements.buttons.airplay, 'click', this.player.airplay, 'airplay');
+ this.bind(
+ player.elements.buttons.airplay,
+ 'click',
+ player.airplay,
+ 'airplay',
+ );
- // Settings menu
- bind(this.player.elements.buttons.settings, 'click', event => {
- controls.toggleMenu.call(this.player, event);
+ // Settings menu - click toggle
+ this.bind(player.elements.buttons.settings, 'click', event => {
+ controls.toggleMenu.call(player, event);
});
- // Settings menu
- bind(this.player.elements.settings.form, 'click', event => {
- event.stopPropagation();
-
- // Go back to home tab on click
- const showHomeTab = () => {
- const id = `plyr-settings-${this.player.id}-home`;
- controls.showTab.call(this.player, id);
- };
-
- // Settings menu items - use event delegation as items are added/removed
- if (matches(event.target, this.player.config.selectors.inputs.language)) {
- proxy(
- event,
- () => {
- this.player.currentTrack = Number(event.target.value);
- showHomeTab();
- },
- 'language',
- );
- } else if (matches(event.target, this.player.config.selectors.inputs.quality)) {
- proxy(
- event,
- () => {
- this.player.quality = event.target.value;
- showHomeTab();
- },
- 'quality',
- );
- } else if (matches(event.target, this.player.config.selectors.inputs.speed)) {
- proxy(
- event,
- () => {
- this.player.speed = parseFloat(event.target.value);
- showHomeTab();
- },
- 'speed',
- );
- } else {
- const tab = event.target;
- controls.showTab.call(this.player, tab.getAttribute('aria-controls'));
- }
- });
+ // Settings menu - keyboard toggle
+ this.bind(
+ player.elements.buttons.settings,
+ 'keydown',
+ event => {
+ // We only care about space
+ if (event.which !== 32) {
+ return;
+ }
+
+ // Prevent scroll
+ event.preventDefault();
+
+ // Prevent playing video
+ event.stopPropagation();
+
+ // Toggle menu
+ controls.toggleMenu.call(player, event);
+ },
+ null,
+ false,
+ );
// Set range input alternative "value", which matches the tooltip time (#954)
- bind(this.player.elements.inputs.seek, 'mousedown mousemove', event => {
- const clientRect = this.player.elements.progress.getBoundingClientRect();
- const percent = 100 / clientRect.width * (event.pageX - clientRect.left);
+ this.bind(player.elements.inputs.seek, 'mousedown mousemove', event => {
+ const rect = player.elements.progress.getBoundingClientRect();
+ const percent = 100 / rect.width * (event.pageX - rect.left);
event.currentTarget.setAttribute('seek-value', percent);
});
// Pause while seeking
- bind(this.player.elements.inputs.seek, 'mousedown mouseup keydown keyup touchstart touchend', event => {
- const seek = event.currentTarget;
+ this.bind(
+ player.elements.inputs.seek,
+ 'mousedown mouseup keydown keyup touchstart touchend',
+ event => {
+ const seek = event.currentTarget;
+ const code = event.keyCode ? event.keyCode : event.which;
+ const eventType = event.type;
+ const attribute = 'play-on-seeked';
+
+ if (
+ (eventType === 'keydown' || eventType === 'keyup') &&
+ (code !== 39 && code !== 37)
+ ) {
+ return;
+ }
+ // Was playing before?
+ const play = seek.hasAttribute(attribute);
- const code = event.keyCode ? event.keyCode : event.which;
- const eventType = event.type;
+ // Done seeking
+ const done = ['mouseup', 'touchend', 'keyup'].includes(
+ event.type,
+ );
- if ((eventType === 'keydown' || eventType === 'keyup') && (code !== 39 && code !== 37)) {
- return;
- }
- // Was playing before?
- const play = seek.hasAttribute('play-on-seeked');
-
- // Done seeking
- const done = ['mouseup', 'touchend', 'keyup'].includes(event.type);
-
- // If we're done seeking and it was playing, resume playback
- if (play && done) {
- seek.removeAttribute('play-on-seeked');
- this.player.play();
- } else if (!done && this.player.playing) {
- seek.setAttribute('play-on-seeked', '');
- this.player.pause();
- }
- });
+ // If we're done seeking and it was playing, resume playback
+ if (play && done) {
+ seek.removeAttribute(attribute);
+ player.play();
+ } else if (!done && player.playing) {
+ seek.setAttribute(attribute, '');
+ player.pause();
+ }
+ },
+ );
// Seek
- bind(
- this.player.elements.inputs.seek,
+ this.bind(
+ player.elements.inputs.seek,
inputEvent,
event => {
const seek = event.currentTarget;
@@ -580,73 +777,105 @@ class Listeners {
seek.removeAttribute('seek-value');
- this.player.currentTime = seekTo / seek.max * this.player.duration;
+ // Super weird iOS bug where after you interact with an <input type="range">,
+ // it takes over further interactions on the page. This is a hack
+ if (browser.isIos) {
+ repaint(seek);
+ }
+
+ player.currentTime = seekTo / seek.max * player.duration;
},
'seek',
);
// Current time invert
// Only if one time element is used for both currentTime and duration
- if (this.player.config.toggleInvert && !is.element(this.player.elements.display.duration)) {
- bind(this.player.elements.display.currentTime, 'click', () => {
+ if (
+ player.config.toggleInvert &&
+ !is.element(player.elements.display.duration)
+ ) {
+ this.bind(player.elements.display.currentTime, 'click', () => {
// Do nothing if we're at the start
- if (this.player.currentTime === 0) {
+ if (player.currentTime === 0) {
return;
}
- this.player.config.invertTime = !this.player.config.invertTime;
+ player.config.invertTime = !player.config.invertTime;
- controls.timeUpdate.call(this.player);
+ controls.timeUpdate.call(player);
});
}
// Volume
- bind(
- this.player.elements.inputs.volume,
+ this.bind(
+ player.elements.inputs.volume,
inputEvent,
event => {
- this.player.volume = event.target.value;
+ player.volume = event.target.value;
},
'volume',
);
// Polyfill for lower fill in <input type="range"> for webkit
if (browser.isWebkit) {
- Array.from(getElements.call(this.player, 'input[type="range"]')).forEach(element => {
- bind(element, 'input', event => controls.updateRangeFill.call(this.player, event.target));
- });
+ Array.from(getElements.call(player, 'input[type="range"]')).forEach(
+ element => {
+ this.bind(element, 'input', event =>
+ controls.updateRangeFill.call(player, event.target),
+ );
+ },
+ );
}
// Seek tooltip
- bind(this.player.elements.progress, 'mouseenter mouseleave mousemove', event =>
- controls.updateSeekTooltip.call(this.player, event),
+ this.bind(
+ player.elements.progress,
+ 'mouseenter mouseleave mousemove',
+ event => controls.updateSeekTooltip.call(player, event),
);
// Update controls.hover state (used for ui.toggleControls to avoid hiding when interacting)
- bind(this.player.elements.controls, 'mouseenter mouseleave', event => {
- this.player.elements.controls.hover = !this.player.touch && event.type === 'mouseenter';
+ this.bind(player.elements.controls, 'mouseenter mouseleave', event => {
+ player.elements.controls.hover =
+ !player.touch && event.type === 'mouseenter';
});
// Update controls.pressed state (used for ui.toggleControls to avoid hiding when interacting)
- bind(this.player.elements.controls, 'mousedown mouseup touchstart touchend touchcancel', event => {
- this.player.elements.controls.pressed = ['mousedown', 'touchstart'].includes(event.type);
- });
+ this.bind(
+ player.elements.controls,
+ 'mousedown mouseup touchstart touchend touchcancel',
+ event => {
+ player.elements.controls.pressed = [
+ 'mousedown',
+ 'touchstart',
+ ].includes(event.type);
+ },
+ );
// Focus in/out on controls
- bind(this.player.elements.controls, 'focusin focusout', event => {
- const { config, elements, timers } = this.player;
+ this.bind(player.elements.controls, 'focusin focusout', event => {
+ 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, event.type === 'focusin');
+ toggleClass(
+ elements.controls,
+ config.classNames.noTransition,
+ isFocusIn,
+ );
// Toggle
- ui.toggleControls.call(this.player, event.type === 'focusin');
+ ui.toggleControls.call(player, isFocusIn);
// If focusin, hide again after delay
- if (event.type === 'focusin') {
+ if (isFocusIn) {
// Restore transition
setTimeout(() => {
- toggleClass(elements.controls, config.classNames.noTransition, false);
+ toggleClass(
+ elements.controls,
+ config.classNames.noTransition,
+ false,
+ );
}, 0);
// Delay a little more for keyboard users
@@ -654,14 +883,18 @@ class Listeners {
// Clear timer
clearTimeout(timers.controls);
+
// Hide
- timers.controls = setTimeout(() => ui.toggleControls.call(this.player, false), delay);
+ timers.controls = setTimeout(
+ () => ui.toggleControls.call(player, false),
+ delay,
+ );
}
});
// Mouse wheel for volume
- bind(
- this.player.elements.inputs.volume,
+ this.bind(
+ player.elements.inputs.volume,
'wheel',
event => {
// Detect "natural" scroll - suppored on OS X Safari only
@@ -669,17 +902,22 @@ class Listeners {
const inverted = event.webkitDirectionInvertedFromDevice;
// Get delta from event. Invert if `inverted` is true
- const [x, y] = [event.deltaX, -event.deltaY].map(value => (inverted ? -value : value));
+ const [x, y] = [event.deltaX, -event.deltaY].map(
+ value => (inverted ? -value : value),
+ );
// Using the biggest delta, normalize to 1 or -1 (or 0 if no delta)
const direction = Math.sign(Math.abs(x) > Math.abs(y) ? x : y);
// Change the volume by 2%
- this.player.increaseVolume(direction / 50);
+ player.increaseVolume(direction / 50);
// Don't break page scrolling at max and min
- const { volume } = this.player.media;
- if ((direction === 1 && volume < 1) || (direction === -1 && volume > 0)) {
+ const { volume } = player.media;
+ if (
+ (direction === 1 && volume < 1) ||
+ (direction === -1 && volume > 0)
+ ) {
event.preventDefault();
}
},
diff --git a/src/js/plyr.js b/src/js/plyr.js
index 65b6c94d..f33b39c7 100644
--- a/src/js/plyr.js
+++ b/src/js/plyr.js
@@ -52,7 +52,11 @@ class Plyr {
}
// jQuery, NodeList or Array passed, use first element
- if ((window.jQuery && this.media instanceof jQuery) || is.nodeList(this.media) || is.array(this.media)) {
+ if (
+ (window.jQuery && this.media instanceof jQuery) ||
+ is.nodeList(this.media) ||
+ is.array(this.media)
+ ) {
// eslint-disable-next-line
this.media = this.media[0];
}
@@ -65,7 +69,9 @@ class Plyr {
options || {},
(() => {
try {
- return JSON.parse(this.media.getAttribute('data-plyr-config'));
+ return JSON.parse(
+ this.media.getAttribute('data-plyr-config'),
+ );
} catch (e) {
return {};
}
@@ -75,16 +81,17 @@ class Plyr {
// Elements cache
this.elements = {
container: null,
+ captions: null,
buttons: {},
display: {},
progress: {},
inputs: {},
settings: {
+ popup: null,
menu: null,
- panes: {},
- tabs: {},
+ panels: {},
+ buttons: {},
},
- captions: null,
};
// Captions
@@ -186,20 +193,28 @@ class Plyr {
if (this.isYouTube) {
this.config.playsinline = truthy.includes(url.searchParams.get('playsinline'));
this.config.hl = url.searchParams.get('hl');
+ );
} else {
this.config.playsinline = true;
}
}
} else {
// <div> with attributes
- this.provider = this.media.getAttribute(this.config.attributes.embed.provider);
+ this.provider = this.media.getAttribute(
+ this.config.attributes.embed.provider,
+ );
// Remove attribute
- this.media.removeAttribute(this.config.attributes.embed.provider);
+ this.media.removeAttribute(
+ this.config.attributes.embed.provider,
+ );
}
// Unsupported or missing provider
- if (is.empty(this.provider) || !Object.keys(providers).includes(this.provider)) {
+ if (
+ is.empty(this.provider) ||
+ !Object.keys(providers).includes(this.provider)
+ ) {
this.debug.error('Setup failed: Invalid provider');
return;
}
@@ -221,7 +236,10 @@ class Plyr {
if (this.media.hasAttribute('autoplay')) {
this.config.autoplay = true;
}
- if (this.media.hasAttribute('playsinline')) {
+ if (
+ this.media.hasAttribute('playsinline') ||
+ this.media.hasAttribute('webkit-playsinline')
+ ) {
this.config.playsinline = true;
}
if (this.media.hasAttribute('muted')) {
@@ -239,7 +257,11 @@ class Plyr {
}
// Check for support again but with type
- this.supported = support.check(this.type, this.provider, this.config.playsinline);
+ this.supported = support.check(
+ this.type,
+ this.provider,
+ this.config.playsinline,
+ );
// If no support for even API, bail
if (!this.supported.api) {
@@ -272,9 +294,14 @@ class Plyr {
// Listen for events if debugging
if (this.config.debug) {
- on.call(this, this.elements.container, this.config.events.join(' '), event => {
- this.debug.log(`event: ${event.type}`);
- });
+ on.call(
+ this,
+ this.elements.container,
+ this.config.events.join(' '),
+ event => {
+ this.debug.log(`event: ${event.type}`);
+ },
+ );
}
// Setup interface
@@ -293,7 +320,9 @@ class Plyr {
this.fullscreen = new Fullscreen(this);
// Setup ads if provided
- this.ads = new Ads(this);
+ if (this.config.ads.enabled) {
+ this.ads = new Ads(this);
+ }
// Autoplay if required
if (this.config.autoplay) {
@@ -422,7 +451,9 @@ class Plyr {
* @param {number} seekTime - how far to rewind in seconds. Defaults to the config.seekTime
*/
rewind(seekTime) {
- this.currentTime = this.currentTime - (is.number(seekTime) ? seekTime : this.config.seekTime);
+ this.currentTime =
+ this.currentTime -
+ (is.number(seekTime) ? seekTime : this.config.seekTime);
}
/**
@@ -430,7 +461,9 @@ class Plyr {
* @param {number} seekTime - how far to fast forward in seconds. Defaults to the config.seekTime
*/
forward(seekTime) {
- this.currentTime = this.currentTime + (is.number(seekTime) ? seekTime : this.config.seekTime);
+ this.currentTime =
+ this.currentTime +
+ (is.number(seekTime) ? seekTime : this.config.seekTime);
}
/**
@@ -447,7 +480,9 @@ class Plyr {
const inputIsValid = is.number(input) && input > 0;
// Set
- this.media.currentTime = inputIsValid ? Math.min(input, this.duration) : 0;
+ this.media.currentTime = inputIsValid
+ ? Math.min(input, this.duration)
+ : 0;
// Logging
this.debug.log(`Seeking to ${this.currentTime} seconds`);
@@ -497,7 +532,10 @@ class Plyr {
// Media duration can be NaN or Infinity before the media has loaded
const realDuration = (this.media || {}).duration;
- const duration = !is.number(realDuration) || realDuration === Infinity ? 0 : realDuration;
+ const duration =
+ !is.number(realDuration) || realDuration === Infinity
+ ? 0
+ : realDuration;
// If config duration is funky, use regular duration
return fauxDuration || duration;
@@ -691,12 +729,16 @@ class Plyr {
if (!options.includes(quality)) {
const value = closest(options, quality);
- this.debug.warn(`Unsupported quality option: ${quality}, using ${value} instead`);
+ this.debug.warn(
+ `Unsupported quality option: ${quality}, using ${value} instead`,
+ );
quality = value;
}
// Trigger request event
- triggerEvent.call(this, this.media, 'qualityrequested', false, { quality });
+ triggerEvent.call(this, this.media, 'qualityrequested', false, {
+ quality,
+ });
// Update config
config.selected = quality;
@@ -888,7 +930,9 @@ class Plyr {
const toggle = is.boolean(input) ? input : this.pip === states.inline;
// Toggle based on current state
- this.media.webkitSetPresentationMode(toggle ? states.pip : states.inline);
+ this.media.webkitSetPresentationMode(
+ toggle ? states.pip : states.inline,
+ );
}
/**
@@ -921,25 +965,39 @@ class Plyr {
// Don't toggle if missing UI support or if it's audio
if (this.supported.ui && !this.isAudio) {
// Get state before change
- const isHidden = hasClass(this.elements.container, this.config.classNames.hideControls);
+ const isHidden = hasClass(
+ this.elements.container,
+ this.config.classNames.hideControls,
+ );
// Negate the argument if not undefined since adding the class to hides the controls
const force = typeof toggle === 'undefined' ? undefined : !toggle;
// Apply and get updated state
- const hiding = toggleClass(this.elements.container, this.config.classNames.hideControls, force);
+ const hiding = toggleClass(
+ this.elements.container,
+ this.config.classNames.hideControls,
+ force,
+ );
// Close menu
- if (hiding && this.config.controls.includes('settings') && !is.empty(this.config.settings)) {
+ if (
+ hiding &&
+ this.config.controls.includes('settings') &&
+ !is.empty(this.config.settings)
+ ) {
controls.toggleMenu.call(this, false);
}
+
// Trigger event on change
if (hiding !== isHidden) {
const eventName = hiding ? 'controlshidden' : 'controlsshown';
triggerEvent.call(this, this.media, eventName);
}
+
return !hiding;
}
+
return false;
}
@@ -1017,7 +1075,12 @@ class Plyr {
replaceElement(this.elements.original, this.elements.container);
// Event
- triggerEvent.call(this, this.elements.original, 'destroyed', true);
+ triggerEvent.call(
+ this,
+ this.elements.original,
+ 'destroyed',
+ true,
+ );
// Callback
if (is.function(callback)) {
diff --git a/src/js/support.js b/src/js/support.js
index 6395293f..4681f5c7 100644
--- a/src/js/support.js
+++ b/src/js/support.js
@@ -25,9 +25,13 @@ const support = {
// Check for support
// Basic functionality vs full UI
check(type, provider, playsinline) {
- const canPlayInline = browser.isIPhone && playsinline && support.playsinline;
+ const canPlayInline =
+ browser.isIPhone && playsinline && support.playsinline;
const api = support[type] || provider !== 'html5';
- const ui = api && support.rangeInput && (type !== 'video' || !browser.isIPhone || canPlayInline);
+ const ui =
+ api &&
+ support.rangeInput &&
+ (type !== 'video' || !browser.isIPhone || canPlayInline);
return {
api,
@@ -37,7 +41,9 @@ const support = {
// Picture-in-picture support
// Safari only currently
- pip: (() => !browser.isIPhone && is.function(createElement('video').webkitSetPresentationMode))(),
+ pip: (() =>
+ !browser.isIPhone &&
+ is.function(createElement('video').webkitSetPresentationMode))(),
// Airplay support
// Safari only currently
@@ -69,7 +75,9 @@ const support = {
}
try {
- return Boolean(type && this.media.canPlayType(type).replace(/no/, ''));
+ return Boolean(
+ type && this.media.canPlayType(type).replace(/no/, ''),
+ );
} catch (err) {
return false;
}
@@ -94,7 +102,9 @@ const support = {
// Reduced motion iOS & MacOS setting
// https://webkit.org/blog/7551/responsive-design-for-motion/
- reducedMotion: 'matchMedia' in window && window.matchMedia('(prefers-reduced-motion)').matches,
+ reducedMotion:
+ 'matchMedia' in window &&
+ window.matchMedia('(prefers-reduced-motion)').matches,
};
export default support;
diff --git a/src/js/ui.js b/src/js/ui.js
index 34fe7e82..8c61d805 100644
--- a/src/js/ui.js
+++ b/src/js/ui.js
@@ -14,8 +14,16 @@ import loadImage from './utils/loadImage';
const ui = {
addStyleHook() {
- toggleClass(this.elements.container, this.config.selectors.container.replace('.', ''), true);
- toggleClass(this.elements.container, this.config.classNames.uiSupported, this.supported.ui);
+ toggleClass(
+ this.elements.container,
+ this.config.selectors.container.replace('.', ''),
+ true,
+ );
+ toggleClass(
+ this.elements.container,
+ this.config.classNames.uiSupported,
+ this.supported.ui,
+ );
},
// Toggle native HTML5 media controls
@@ -35,7 +43,9 @@ const ui = {
// Don't setup interface if no support
if (!this.supported.ui) {
- this.debug.warn(`Basic support only for ${this.provider} ${this.type}`);
+ this.debug.warn(
+ `Basic support only for ${this.provider} ${this.type}`,
+ );
// Restore native controls
ui.toggleNativeControls.call(this, true);
@@ -93,13 +103,25 @@ const ui = {
);
// Check for airplay support
- toggleClass(this.elements.container, this.config.classNames.airplay.supported, support.airplay && this.isHTML5);
+ toggleClass(
+ this.elements.container,
+ this.config.classNames.airplay.supported,
+ support.airplay && this.isHTML5,
+ );
// Add iOS class
- toggleClass(this.elements.container, this.config.classNames.isIos, browser.isIos);
+ toggleClass(
+ this.elements.container,
+ this.config.classNames.isIos,
+ browser.isIos,
+ );
// Add touch class
- toggleClass(this.elements.container, this.config.classNames.isTouch, this.touch);
+ toggleClass(
+ this.elements.container,
+ this.config.classNames.isTouch,
+ this.touch,
+ );
// Ready for API calls
this.ready = true;
@@ -149,7 +171,9 @@ const ui = {
}
// Default to media type
- const title = !is.empty(this.config.title) ? this.config.title : 'video';
+ const title = !is.empty(this.config.title)
+ ? this.config.title
+ : 'video';
const format = i18n.get('frameTitle', this.config);
iframe.setAttribute('title', format.replace('{title}', title));
@@ -158,7 +182,11 @@ const ui = {
// Toggle poster
togglePoster(enable) {
- toggleClass(this.elements.container, this.config.classNames.posterEnabled, enable);
+ toggleClass(
+ this.elements.container,
+ this.config.classNames.posterEnabled,
+ enable,
+ );
},
// Set the poster image (async)
@@ -189,7 +217,9 @@ const ui = {
.then(() => {
// Prevent race conditions
if (poster !== this.poster) {
- throw new Error('setPoster cancelled by later call to setPoster');
+ throw new Error(
+ 'setPoster cancelled by later call to setPoster',
+ );
}
})
.then(() => {
@@ -207,9 +237,21 @@ const ui = {
// Check playing state
checkPlaying(event) {
// Class hooks
- toggleClass(this.elements.container, this.config.classNames.playing, this.playing);
- toggleClass(this.elements.container, this.config.classNames.paused, this.paused);
- toggleClass(this.elements.container, this.config.classNames.stopped, this.stopped);
+ toggleClass(
+ this.elements.container,
+ this.config.classNames.playing,
+ this.playing,
+ );
+ toggleClass(
+ this.elements.container,
+ this.config.classNames.paused,
+ this.paused,
+ );
+ toggleClass(
+ this.elements.container,
+ this.config.classNames.stopped,
+ this.stopped,
+ );
// Set state
Array.from(this.elements.buttons.play || []).forEach(target => {
@@ -235,7 +277,11 @@ const ui = {
// 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);
+ toggleClass(
+ this.elements.container,
+ this.config.classNames.loading,
+ this.loading,
+ );
// Update controls visibility
ui.toggleControls.call(this);
@@ -248,7 +294,15 @@ const ui = {
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));
+ this.toggleControls(
+ Boolean(
+ force ||
+ this.loading ||
+ this.paused ||
+ controls.pressed ||
+ controls.hover,
+ ),
+ );
}
},
};
diff --git a/src/js/utils/animation.js b/src/js/utils/animation.js
index 95e39f03..49bc0b8c 100644
--- a/src/js/utils/animation.js
+++ b/src/js/utils/animation.js
@@ -15,7 +15,9 @@ export const transitionEndEvent = (() => {
transition: 'transitionend',
};
- const type = Object.keys(events).find(event => element.style[event] !== undefined);
+ const type = Object.keys(events).find(
+ event => element.style[event] !== undefined,
+ );
return is.string(type) ? events[type] : false;
})();
@@ -23,8 +25,12 @@ export const transitionEndEvent = (() => {
// Force repaint of element
export function repaint(element) {
setTimeout(() => {
- toggleHidden(element, true);
- element.offsetHeight; // eslint-disable-line
- toggleHidden(element, false);
+ try {
+ toggleHidden(element, true);
+ element.offsetHeight; // eslint-disable-line
+ toggleHidden(element, false);
+ } catch (e) {
+ // Do nothing
+ }
}, 0);
}
diff --git a/src/js/utils/elements.js b/src/js/utils/elements.js
index 69e4d46c..3a3dfcfd 100644
--- a/src/js/utils/elements.js
+++ b/src/js/utils/elements.js
@@ -70,12 +70,19 @@ export function createElement(type, attributes, text) {
// Inaert an element after another
export function insertAfter(element, target) {
+ if (!is.element(element) || !is.element(target)) {
+ return;
+ }
+
target.parentNode.insertBefore(element, target.nextSibling);
}
// Insert a DocumentFragment
export function insertElement(type, parent, attributes, text) {
- // Inject the new <element>
+ if (!is.element(parent)) {
+ return;
+ }
+
parent.appendChild(createElement(type, attributes, text));
}
@@ -95,6 +102,10 @@ export function removeElement(element) {
// Remove all child elements
export function emptyElement(element) {
+ if (!is.element(element)) {
+ return;
+ }
+
let { length } = element.childNodes;
while (length > 0) {
@@ -105,7 +116,11 @@ export function emptyElement(element) {
// Replace element
export function replaceElement(newChild, oldChild) {
- if (!is.element(oldChild) || !is.element(oldChild.parentNode) || !is.element(newChild)) {
+ if (
+ !is.element(oldChild) ||
+ !is.element(oldChild.parentNode) ||
+ !is.element(newChild)
+ ) {
return null;
}
@@ -192,6 +207,10 @@ export function toggleHidden(element, hidden) {
// Mirror Element.classList.toggle, with IE compatibility for "force" argument
export function toggleClass(element, className, force) {
+ if (is.nodeList(element)) {
+ return Array.from(element).map(e => toggleClass(e, className, force));
+ }
+
if (is.element(element)) {
let method = 'toggle';
if (typeof force !== 'undefined') {
@@ -202,7 +221,7 @@ export function toggleClass(element, className, force) {
return element.classList.contains(className);
}
- return null;
+ return false;
}
// Has class name
@@ -238,26 +257,16 @@ export function getElement(selector) {
return this.elements.container.querySelector(selector);
}
-// Get the focused element
-export function getFocusElement() {
- let focused = document.activeElement;
-
- if (!focused || focused === document.body) {
- focused = null;
- } else {
- focused = document.querySelector(':focus');
- }
-
- return focused;
-}
-
// Trap focus inside container
export function trapFocus(element = null, toggle = false) {
if (!is.element(element)) {
return;
}
- const focusable = getElements.call(this, 'button:not(:disabled), input:not(:disabled), [tabindex]');
+ const focusable = getElements.call(
+ this,
+ 'button:not(:disabled), input:not(:disabled), [tabindex]',
+ );
const first = focusable[0];
const last = focusable[focusable.length - 1];
@@ -268,7 +277,7 @@ export function trapFocus(element = null, toggle = false) {
}
// Get the current focused element
- const focused = getFocusElement();
+ const focused = document.activeElement;
if (focused === last && !event.shiftKey) {
// Move focus to first element that can be tabbed if Shift isn't used
@@ -281,5 +290,27 @@ export function trapFocus(element = null, toggle = false) {
}
};
- toggleListener.call(this, this.elements.container, 'keydown', trap, toggle, false);
+ toggleListener.call(
+ this,
+ this.elements.container,
+ 'keydown',
+ trap,
+ toggle,
+ false,
+ );
+}
+
+// Set focus and tab focus class
+export function setFocus(element = null, tabFocus = false) {
+ if (!is.element(element)) {
+ return;
+ }
+
+ // Set regular focus
+ element.focus();
+
+ // If we want to mimic keyboard focus via tab
+ if (tabFocus) {
+ toggleClass(element, this.config.classNames.tabFocus);
+ }
}
diff --git a/src/js/utils/events.js b/src/js/utils/events.js
index 9f734f04..9820fcae 100644
--- a/src/js/utils/events.js
+++ b/src/js/utils/events.js
@@ -27,9 +27,21 @@ const supportsPassiveListeners = (() => {
})();
// Toggle event listener
-export function toggleListener(element, event, callback, toggle = false, passive = true, capture = false) {
+export function toggleListener(
+ element,
+ event,
+ callback,
+ toggle = false,
+ passive = true,
+ capture = false,
+) {
// Bail if no element, event, or callback
- if (!element || !('addEventListener' in element) || is.empty(event) || !is.function(callback)) {
+ if (
+ !element ||
+ !('addEventListener' in element) ||
+ is.empty(event) ||
+ !is.function(callback)
+ ) {
return;
}
@@ -57,28 +69,74 @@ export function toggleListener(element, event, callback, toggle = false, passive
this.eventListeners.push({ element, type, callback, options });
}
- element[toggle ? 'addEventListener' : 'removeEventListener'](type, callback, options);
+ element[toggle ? 'addEventListener' : 'removeEventListener'](
+ type,
+ callback,
+ options,
+ );
});
}
// Bind event handler
-export function on(element, events = '', callback, passive = true, capture = false) {
- toggleListener.call(this, element, events, callback, true, passive, capture);
+export function on(
+ element,
+ events = '',
+ callback,
+ passive = true,
+ capture = false,
+) {
+ toggleListener.call(
+ this,
+ element,
+ events,
+ callback,
+ true,
+ passive,
+ capture,
+ );
}
// Unbind event handler
-export function off(element, events = '', callback, passive = true, capture = false) {
- toggleListener.call(this, element, events, callback, false, passive, capture);
+export function off(
+ element,
+ events = '',
+ callback,
+ passive = true,
+ capture = false,
+) {
+ toggleListener.call(
+ this,
+ element,
+ events,
+ callback,
+ false,
+ passive,
+ capture,
+ );
}
// Bind once-only event handler
-export function once(element, events = '', callback, passive = true, capture = false) {
+export function once(
+ element,
+ events = '',
+ callback,
+ passive = true,
+ capture = false,
+) {
function onceCallback(...args) {
off(element, events, onceCallback, passive, capture);
callback.apply(this, args);
}
- toggleListener.call(this, element, events, onceCallback, true, passive, capture);
+ toggleListener.call(
+ this,
+ element,
+ events,
+ onceCallback,
+ true,
+ passive,
+ capture,
+ );
}
// Trigger event
@@ -115,6 +173,9 @@ export function unbindListeners() {
// Run method when / if player is ready
export function ready() {
return new Promise(
- resolve => (this.ready ? setTimeout(resolve, 0) : on.call(this, this.elements.container, 'ready', resolve)),
+ resolve =>
+ this.ready
+ ? setTimeout(resolve, 0)
+ : on.call(this, this.elements.container, 'ready', resolve),
).then(() => {});
}