diff options
author | Sam Potts <sam@potts.es> | 2018-08-02 00:47:57 +1000 |
---|---|---|
committer | GitHub <noreply@github.com> | 2018-08-02 00:47:57 +1000 |
commit | 18b4d26bee07e1d1079e39ded8a30eaed8b02ff9 (patch) | |
tree | 8456332396352cce0afa3f9f598d2ffb11fdc0f6 /src | |
parent | fa0861ff2e9b22e361ada7b1fb9ff45c0378464e (diff) | |
parent | 7f4b74e2d43ed95a695f760f48e5b8e0792f9528 (diff) | |
download | plyr-18b4d26bee07e1d1079e39ded8a30eaed8b02ff9.tar.lz plyr-18b4d26bee07e1d1079e39ded8a30eaed8b02ff9.tar.xz plyr-18b4d26bee07e1d1079e39ded8a30eaed8b02ff9.zip |
Merge pull request #1142 from sampotts/a11y-improvements
A11y improvements
Diffstat (limited to 'src')
-rw-r--r-- | src/js/captions.js | 4 | ||||
-rw-r--r-- | src/js/config/defaults.js | 3 | ||||
-rw-r--r-- | src/js/controls.js | 658 | ||||
-rw-r--r-- | src/js/listeners.js | 569 | ||||
-rw-r--r-- | src/js/plugins/ads.js | 9 | ||||
-rw-r--r-- | src/js/plyr.js | 22 | ||||
-rw-r--r-- | src/js/utils/animation.js | 14 | ||||
-rw-r--r-- | src/js/utils/elements.js | 69 | ||||
-rw-r--r-- | src/js/utils/is.js | 2 | ||||
-rw-r--r-- | src/sass/components/control.scss | 17 | ||||
-rw-r--r-- | src/sass/components/controls.scss | 44 | ||||
-rw-r--r-- | src/sass/components/menus.scss | 83 | ||||
-rw-r--r-- | src/sass/components/poster.scss | 3 | ||||
-rw-r--r-- | src/sass/components/progress.scss | 1 | ||||
-rw-r--r-- | src/sass/components/sliders.scss | 18 | ||||
-rw-r--r-- | src/sass/lib/mixins.scss | 4 | ||||
-rw-r--r-- | src/sass/utils/hidden.scss | 4 |
17 files changed, 900 insertions, 624 deletions
diff --git a/src/js/captions.js b/src/js/captions.js index 14f77a2e..4ad2a956 100644 --- a/src/js/captions.js +++ b/src/js/captions.js @@ -84,7 +84,9 @@ const captions = { // * toggled: The real captions state const languages = dedupe( - Array.from(navigator.languages || navigator.language || navigator.userLanguage).map(language => language.split('-')[0]), + Array.from(navigator.languages || navigator.language || navigator.userLanguage).map( + language => language.split('-')[0], + ), ); let language = (this.storage.get('language') || this.config.captions.language || 'auto').toLowerCase(); 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 c01e3584..fa03a36d 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'; @@ -243,12 +232,28 @@ const controls = { // Setup toggle icon and labels if (toggle) { // Icon - button.appendChild(controls.createIcon.call(this, iconPressed, { class: 'icon--pressed' })); - button.appendChild(controls.createIcon.call(this, icon, { class: 'icon--not-pressed' })); + button.appendChild( + controls.createIcon.call(this, iconPressed, { + class: 'icon--pressed', + }), + ); + button.appendChild( + controls.createIcon.call(this, icon, { + class: 'icon--not-pressed', + }), + ); // Label/Tooltip - button.appendChild(controls.createLabel.call(this, labelPressed, { class: 'label--pressed' })); - button.appendChild(controls.createLabel.call(this, label, { class: 'label--not-pressed' })); + button.appendChild( + controls.createLabel.call(this, labelPressed, { + class: 'label--pressed', + }), + ); + button.appendChild( + controls.createLabel.call(this, label, { + class: 'label--not-pressed', + }), + ); } else { button.appendChild(controls.createIcon.call(this, icon)); button.appendChild(controls.createLabel.call(this, label)); @@ -360,7 +365,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 +377,143 @@ const controls = { return container; }, + // Bind keyboard shortcuts for a menu item + // We have to bind to keyup otherwise Firefox triggers a click when a keydown event handler shifts focus + // https://bugzilla.mozilla.org/show_bug.cgi?id=1220143 + bindMenuItemShortcuts(menuItem, type) { + // Handle space or -> to open menu + on( + menuItem, + 'keydown keyup', + event => { + // We only care about space and ⬆️ ⬇️️ ➡️ + if (![32, 38, 39, 40].includes(event.which)) { + return; + } + + // Prevent play / seek + event.preventDefault(); + event.stopPropagation(); + + // We're just here to prevent the keydown bubbling + if (event.type === 'keydown') { + return; + } + + 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 keyup', + event => { + if (is.keyboardEvent(event) && 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', is.keyboardEvent(event)); + }, + type, + false, + ); + + controls.bindMenuItemShortcuts.call(this, menuItem, type); + + list.appendChild(menuItem); }, // Format a time for display @@ -637,7 +748,7 @@ const controls = { // https://github.com/video-dev/hls.js/blob/5820d29d3c4c8a46e8b75f1e3afa3e68c1a9a2db/src/controller/buffer-controller.js#L415 // https://github.com/google/shaka-player/blob/4d889054631f4e1cf0fbd80ddd2b71887c02e232/lib/media/streaming_engine.js#L1062 // https://github.com/Dash-Industry-Forum/dash.js/blob/69859f51b969645b234666800d4cb596d89c602d/src/dash/models/DashManifestModel.js#L338 - if (this.duration >= 2**32) { + if (this.duration >= 2 ** 32) { toggleHidden(this.elements.display.currentTime, true); toggleHidden(this.elements.progress, true); return; @@ -666,19 +777,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 +876,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 +889,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 +919,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 +968,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 +989,7 @@ const controls = { controls.checkMenu.call(this); // If there's no captions, bail - if (!tracks.length) { + if (!toggle) { return; } @@ -903,17 +1020,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 +1040,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 +1053,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,29 +1068,35 @@ 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'); + // True toggle by default + const hidden = popup.hasAttribute('hidden'); + let show = hidden; - if (is.event(event)) { - const isMenuItem = is.element(form) && form.contains(event.target); - const isButton = event.target === this.elements.buttons.settings; + if (is.boolean(input)) { + show = input; + } else if (is.keyboardEvent(input) && input.which === 27) { + show = false; + } else if (is.event(input)) { + const isMenuItem = popup.contains(input.target); + const isButton = input.target === button; - // If the click was inside the form or if the click + // If the click was inside the menu or if the click // wasn't the button or menu item and we're trying to // show the menu (a doc click shouldn't show the menu) if (isMenuItem || (!isMenuItem && !isButton && show)) { @@ -989,40 +1105,38 @@ const controls = { // Prevent the toggle being caught by the doc listener if (isButton) { - event.stopPropagation(); + input.stopPropagation(); } } - // Set form and button attributes - if (is.element(button)) { - button.setAttribute('aria-expanded', show); - } + // Set button attributes + button.setAttribute('aria-expanded', show); - if (is.element(form)) { - toggleHidden(form, !show); - toggleClass(this.elements.container, this.config.classNames.menu.open, show); + // Show the actual popup + toggleHidden(popup, !show); - if (show) { - form.removeAttribute('tabindex'); - } else { - form.setAttribute('tabindex', -1); - } + // Add class hook + toggleClass(this.elements.container, this.config.classNames.menu.open, show); + + // Focus the first item if key interaction + if (show && is.keyboardEvent(input)) { + const pane = Object.values(this.elements.settings.panels).find(pane => !pane.hidden); + const firstItem = pane.querySelector('[role^="menuitem"]'); + setFocus.call(this, firstItem, true); + } + // If closing, re-focus the button + else if (!show && !hidden) { + setFocus.call(this, button, is.keyboardEvent(input)); } }, - // 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 +1153,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 +1173,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 +1200,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 @@ -1225,12 +1320,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, @@ -1239,48 +1334,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, }); @@ -1288,54 +1387,91 @@ 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, + // Back button + const backButton = createElement('button', { + type: 'button', + class: `${this.config.classNames.control} ${this.config.classNames.control}--back`, + }); + + // Visible label + backButton.appendChild( + createElement( + 'span', + { + 'aria-hidden': true, + }, + i18n.get(type, this.config), + ), + ); + + // Screen reader label + backButton.appendChild( + createElement( + 'span', + { + class: this.config.classNames.hidden, + }, + i18n.get('menuBack', this.config), + ), + ); + + // Go back via keyboard + on( + pane, + 'keydown', + event => { + // We only care about <- + if (event.which !== 37) { + return; + } + + // Prevent seek + event.preventDefault(); + event.stopPropagation(); + + // Show the respective menu + controls.showMenuPanel.call(this, 'home', true); }, - i18n.get(type, this.config), + false, ); - pane.appendChild(back); + // 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..8176e9a3 100644 --- a/src/js/listeners.js +++ b/src/js/listeners.js @@ -4,8 +4,9 @@ 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 +14,19 @@ 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 { elements } = player; const code = event.keyCode ? event.keyCode : event.which; const pressed = event.type === 'keydown'; const repeat = pressed && code === this.lastKey; @@ -39,27 +45,32 @@ 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 } = 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 +98,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 +164,8 @@ 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 +182,99 @@ class Listeners { // Device is touch enabled firstTouch() { - this.player.touch = true; + const { player } = this; + const { elements } = player; + + player.touch = true; // Add touch class - toggleClass(this.player.elements.container, this.player.config.classNames.isTouch, true); + toggleClass(elements.container, player.config.classNames.isTouch, true); + } + + setTabFocus(event) { + const { player } = this; + const { elements } = player; + + clearTimeout(this.focusTimer); + + // Ignore any key other than tab + if (event.type === 'keydown' && event.which !== 9) { + 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 (!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; + const { elements } = player; + // 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, 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, + elements.container, 'mousemove mouseleave touchstart touchmove enterfullscreen exitfullscreen', event => { - const { controls } = this.player.elements; + const { controls } = elements; // Remove button states for fullscreen if (event.type === 'enterfullscreen') { @@ -236,85 +288,83 @@ class Listeners { 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; + const { elements } = player; + // 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(elements.volume, !player.hasAudio); + toggleHidden(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 +372,38 @@ 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) { + on.call(player, elements.container, 'click touchstart', event => { + const targets = [elements.container, wrapper]; + + // Ignore if click if not container or in video wrapper + if (!targets.includes(event.target) && !wrapper.contains(event.target)) { + return; + } + + // 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(elements.container, player.config.classNames.hideControls) + ) { return; } - if (this.player.paused) { - this.player.play(); - } else if (this.player.ended) { - this.player.restart(); - this.player.play(); + if (player.ended) { + player.restart(); + player.play(); } else { - this.player.pause(); + 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, + elements.wrapper, 'contextmenu', event => { event.preventDefault(); @@ -353,220 +413,227 @@ 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, 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; + const { elements } = player; + + // 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 (elements.buttons.play) { + Array.from(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(elements.buttons.restart, 'click', player.restart, 'restart'); // Rewind - bind(this.player.elements.buttons.rewind, 'click', this.player.rewind, 'rewind'); + this.bind(elements.buttons.rewind, 'click', player.rewind, 'rewind'); // Rewind - bind(this.player.elements.buttons.fastForward, 'click', this.player.forward, 'fastForward'); + this.bind(elements.buttons.fastForward, 'click', player.forward, 'fastForward'); // Mute toggle - bind( - this.player.elements.buttons.mute, + this.bind( + 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(elements.buttons.captions, 'click', () => player.toggleCaptions()); // Fullscreen toggle - bind( - this.player.elements.buttons.fullscreen, + this.bind( + elements.buttons.fullscreen, 'click', () => { - this.player.fullscreen.toggle(); + player.fullscreen.toggle(); }, 'fullscreen', ); // Picture-in-Picture - bind( - this.player.elements.buttons.pip, + this.bind( + 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(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(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 + // We have to bind to keyup otherwise Firefox triggers a click when a keydown event handler shifts focus + // https://bugzilla.mozilla.org/show_bug.cgi?id=1220143 + this.bind( + elements.buttons.settings, + 'keyup', + event => { + // We only care about space and return + if (event.which !== 32 && event.which !== 13) { + return; + } + + // Prevent scroll + event.preventDefault(); + + // Prevent playing video (Firefox) + if (event.which === 32) { + event.stopPropagation(); + } + + // Toggle menu + controls.toggleMenu.call(player, event); + }, + null, + false, + ); + + // Escape closes menu + this.bind(elements.settings.menu, 'keydown', event => { + if (event.which === 27) { + controls.toggleMenu.call(player, event); } }); // 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(elements.inputs.seek, 'mousedown mousemove', event => { + const rect = 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 => { + this.bind(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)) { + if (is.keyboardEvent(event) && (code !== 39 && code !== 37)) { return; } + // Was playing before? - const play = seek.hasAttribute('play-on-seeked'); + const play = seek.hasAttribute(attribute); // 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(); + seek.removeAttribute(attribute); + player.play(); + } else if (!done && player.playing) { + seek.setAttribute(attribute, ''); + player.pause(); } }); + // Fix range inputs on iOS + // 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) { + const inputs = getElements.call(player, 'input[type="range"]'); + Array.from(inputs).forEach(input => this.bind(input, inputEvent, event => repaint(event.target))); + } + // Seek - bind( - this.player.elements.inputs.seek, + this.bind( + elements.inputs.seek, inputEvent, event => { const seek = event.currentTarget; @@ -580,70 +647,71 @@ class Listeners { seek.removeAttribute('seek-value'); - this.player.currentTime = seekTo / seek.max * this.player.duration; + player.currentTime = seekTo / seek.max * player.duration; }, 'seek', ); + // Seek tooltip + this.bind(elements.progress, 'mouseenter mouseleave mousemove', event => + controls.updateSeekTooltip.call(player, event), + ); + + // Polyfill for lower fill in <input type="range"> for webkit + if (browser.isWebkit) { + Array.from(getElements.call(player, 'input[type="range"]')).forEach(element => { + this.bind(element, 'input', event => controls.updateRangeFill.call(player, event.target)); + }); + } + // 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(elements.display.duration)) { + this.bind(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( + 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)); - }); - } - - // Seek tooltip - bind(this.player.elements.progress, 'mouseenter mouseleave mousemove', event => - controls.updateSeekTooltip.call(this.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(elements.controls, 'mouseenter mouseleave', event => { + 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(elements.controls, 'mousedown mouseup touchstart touchend touchcancel', event => { + 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(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); @@ -654,14 +722,15 @@ 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( + elements.inputs.volume, 'wheel', event => { // Detect "natural" scroll - suppored on OS X Safari only @@ -675,10 +744,10 @@ class Listeners { 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; + const { volume } = player.media; if ((direction === 1 && volume < 1) || (direction === -1 && volume > 0)) { event.preventDefault(); } diff --git a/src/js/plugins/ads.js b/src/js/plugins/ads.js index f5727a1d..8b1aac8c 100644 --- a/src/js/plugins/ads.js +++ b/src/js/plugins/ads.js @@ -207,6 +207,11 @@ class Ads { * @param {Event} adsManagerLoadedEvent */ onAdsManagerLoaded(event) { + // Load could occur after a source change (race condition) + if (!this.enabled) { + return; + } + // Get the ads manager const settings = new google.ima.AdsRenderingSettings(); @@ -240,10 +245,6 @@ class Ads { }); } - // Get skippable state - // TODO: Skip button - // this.player.debug.warn(this.manager.getAdSkippableState()); - // Set volume to match player this.manager.setVolume(this.player.volume); diff --git a/src/js/plyr.js b/src/js/plyr.js index 65b6c94d..b99df2ff 100644 --- a/src/js/plyr.js +++ b/src/js/plyr.js @@ -75,16 +75,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 @@ -185,7 +186,7 @@ class Plyr { // YouTube requires the playsinline in the URL if (this.isYouTube) { this.config.playsinline = truthy.includes(url.searchParams.get('playsinline')); - this.config.hl = url.searchParams.get('hl'); + this.config.hl = url.searchParams.get('hl'); // TODO: Should this be setting language? } else { this.config.playsinline = true; } @@ -221,7 +222,7 @@ 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')) { @@ -293,7 +294,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) { @@ -696,7 +699,9 @@ class Plyr { } // Trigger request event - triggerEvent.call(this, this.media, 'qualityrequested', false, { quality }); + triggerEvent.call(this, this.media, 'qualityrequested', false, { + quality, + }); // Update config config.selected = quality; @@ -933,13 +938,16 @@ class Plyr { 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; } 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/is.js b/src/js/utils/is.js index b4760da4..2952d486 100644 --- a/src/js/utils/is.js +++ b/src/js/utils/is.js @@ -16,6 +16,7 @@ const isNodeList = input => instanceOf(input, NodeList); const isElement = input => instanceOf(input, Element); const isTextNode = input => getConstructor(input) === Text; const isEvent = input => instanceOf(input, Event); +const isKeyboardEvent = input => instanceOf(input, KeyboardEvent); const isCue = input => instanceOf(input, window.TextTrackCue) || instanceOf(input, window.VTTCue); const isTrack = input => instanceOf(input, TextTrack) || (!isNullOrUndefined(input) && isString(input.kind)); @@ -56,6 +57,7 @@ export default { element: isElement, textNode: isTextNode, event: isEvent, + keyboardEvent: isKeyboardEvent, cue: isCue, track: isTrack, url: isUrl, diff --git a/src/sass/components/control.scss b/src/sass/components/control.scss index cfef1b3a..2b681465 100644 --- a/src/sass/components/control.scss +++ b/src/sass/components/control.scss @@ -41,7 +41,7 @@ display: none; } -// Audio styles +// Audio control .plyr--audio .plyr__control { &.plyr__tab-focus, &:hover, @@ -51,6 +51,21 @@ } } +// Video control +.plyr--video .plyr__control { + svg { + filter: drop-shadow(0 1px 1px rgba(#000, 0.15)); + } + + // Hover and tab focus + &.plyr__tab-focus, + &:hover, + &[aria-expanded='true'] { + background: $plyr-video-control-bg-hover; + color: $plyr-video-control-color-hover; + } +} + // Large play button (video only) .plyr__control--overlaid { background: rgba($plyr-video-control-bg-hover, 0.8); diff --git a/src/sass/components/controls.scss b/src/sass/components/controls.scss index 65b7554c..bde8600b 100644 --- a/src/sass/components/controls.scss +++ b/src/sass/components/controls.scss @@ -2,11 +2,6 @@ // Controls // -------------------------------------------------------------- -// Hide empty controls -.plyr__controls:empty { - display: none; -} - // Hide native controls .plyr--full-ui ::-webkit-media-controls { display: none; @@ -37,6 +32,11 @@ margin-left: ($plyr-control-spacing / 2); } + // Hide empty controls + &:empty { + display: none; + } + @media (min-width: $plyr-bp-sm) { > .plyr__control, .plyr__progress, @@ -53,6 +53,14 @@ } } +// Audio controls +.plyr--audio .plyr__controls { + background: $plyr-audio-controls-bg; + border-radius: inherit; + color: $plyr-audio-control-color; + padding: $plyr-control-spacing; +} + // Video controls .plyr--video .plyr__controls { background: linear-gradient( @@ -69,32 +77,10 @@ position: absolute; right: 0; transition: opacity 0.4s ease-in-out, transform 0.4s ease-in-out; - z-index: 2; - - .plyr__control { - svg { - filter: drop-shadow(0 1px 1px rgba(#000, 0.15)); - } - - // Hover and tab focus - &.plyr__tab-focus, - &:hover, - &[aria-expanded='true'] { - background: $plyr-video-control-bg-hover; - color: $plyr-video-control-color-hover; - } - } -} - -// Audio controls -.plyr--audio .plyr__controls { - background: $plyr-audio-controls-bg; - border-radius: inherit; - color: $plyr-audio-control-color; - padding: $plyr-control-spacing; + z-index: 3; } -// Hide controls +// Hide video controls .plyr--video.plyr--hide-controls .plyr__controls { opacity: 0; pointer-events: none; diff --git a/src/sass/components/menus.scss b/src/sass/components/menus.scss index 3ad4039a..b8c85284 100644 --- a/src/sass/components/menus.scss +++ b/src/sass/components/menus.scss @@ -39,7 +39,8 @@ > div { overflow: hidden; - transition: height 0.35s cubic-bezier(0.4, 0, 0.2, 1), width 0.35s cubic-bezier(0.4, 0, 0.2, 1); + transition: height 0.35s cubic-bezier(0.4, 0, 0.2, 1), + width 0.35s cubic-bezier(0.4, 0, 0.2, 1); } // Arrow @@ -54,18 +55,16 @@ width: 0; } - ul { - list-style: none; - margin: 0; - overflow: hidden; + [role='menu'] { padding: $plyr-control-padding; + } - li { - margin-top: 2px; + [role='menuitem'], + [role='menuitemradio'] { + margin-top: 2px; - &:first-child { - margin-top: 0; - } + &:first-child { + margin-top: 0; } } @@ -75,10 +74,17 @@ color: $plyr-menu-color; display: flex; font-size: $plyr-font-size-menu; - padding: ceil($plyr-control-padding / 2) ($plyr-control-padding * 2); + padding: ceil($plyr-control-padding / 2) + ceil($plyr-control-padding * 1.5); user-select: none; width: 100%; + > span { + align-items: inherit; + display: flex; + width: 100%; + } + &::after { border: 4px solid transparent; content: ''; @@ -135,50 +141,49 @@ } } - label.plyr__control { + .plyr__control[role='menuitemradio'] { padding-left: $plyr-control-padding; - input[type='radio'] + span { - background: rgba(#000, 0.1); + &::before, + &::after { border-radius: 100%; + } + + &::before { + background: rgba(#000, 0.1); + content: ''; display: block; flex-shrink: 0; height: 16px; margin-right: $plyr-control-spacing; - position: relative; transition: all 0.3s ease; width: 16px; - - &::after { - background: #fff; - border-radius: 100%; - content: ''; - height: 6px; - left: 5px; - opacity: 0; - position: absolute; - top: 5px; - transform: scale(0); - transition: transform 0.3s ease, opacity 0.3s ease; - width: 6px; - } } - input[type='radio']:checked + span { - background: $plyr-color-main; + &::after { + background: #fff; + border: 0; + height: 6px; + left: 12px; + opacity: 0; + top: 50%; + transform: translateY(-50%) scale(0); + transition: transform 0.3s ease, opacity 0.3s ease; + width: 6px; + } + &[aria-checked='true'] { + &::before { + background: $plyr-color-main; + } &::after { opacity: 1; - transform: scale(1); + transform: translateY(-50%) scale(1); } } - input[type='radio']:focus + span { - @include plyr-tab-focus(); - } - - &.plyr__tab-focus input[type='radio'] + span, - &:hover input[type='radio'] + span { + &.plyr__tab-focus::before, + &:hover::before { background: rgba(#000, 0.1); } } @@ -188,7 +193,7 @@ align-items: center; display: flex; margin-left: auto; - margin-right: -$plyr-control-padding; + margin-right: -($plyr-control-padding - 2); overflow: hidden; padding-left: ceil($plyr-control-padding * 3.5); pointer-events: none; diff --git a/src/sass/components/poster.scss b/src/sass/components/poster.scss index 9bf7398d..9b239d4f 100644 --- a/src/sass/components/poster.scss +++ b/src/sass/components/poster.scss @@ -12,12 +12,11 @@ opacity: 0; position: absolute; top: 0; - transition: opacity 0.3s ease; + transition: opacity 0.2s ease; width: 100%; z-index: 1; } .plyr--stopped.plyr__poster-enabled .plyr__poster { opacity: 1; - pointer-events: none; } diff --git a/src/sass/components/progress.scss b/src/sass/components/progress.scss index eddd32ab..16992808 100644 --- a/src/sass/components/progress.scss +++ b/src/sass/components/progress.scss @@ -3,7 +3,6 @@ // -------------------------------------------------------------- .plyr__progress { - display: flex; flex: 1; left: $plyr-range-thumb-height / 2; margin-right: $plyr-range-thumb-height; diff --git a/src/sass/components/sliders.scss b/src/sass/components/sliders.scss index b9264b05..ee64271b 100644 --- a/src/sass/components/sliders.scss +++ b/src/sass/components/sliders.scss @@ -19,7 +19,11 @@ &::-webkit-slider-runnable-track { @include plyr-range-track(); - background-image: linear-gradient(to right, currentColor var(--value, 0%), transparent var(--value, 0%)); + background-image: linear-gradient( + to right, + currentColor var(--value, 0%), + transparent var(--value, 0%) + ); } &::-webkit-slider-thumb { @@ -140,15 +144,21 @@ // Pressed styles &:active { &::-webkit-slider-thumb { - @include plyr-range-thumb-active($plyr-audio-range-thumb-shadow-color); + @include plyr-range-thumb-active( + $plyr-audio-range-thumb-shadow-color + ); } &::-moz-range-thumb { - @include plyr-range-thumb-active($plyr-audio-range-thumb-shadow-color); + @include plyr-range-thumb-active( + $plyr-audio-range-thumb-shadow-color + ); } &::-ms-thumb { - @include plyr-range-thumb-active($plyr-audio-range-thumb-shadow-color); + @include plyr-range-thumb-active( + $plyr-audio-range-thumb-shadow-color + ); } } } diff --git a/src/sass/lib/mixins.scss b/src/sass/lib/mixins.scss index 7bbf94f5..e015ffee 100644 --- a/src/sass/lib/mixins.scss +++ b/src/sass/lib/mixins.scss @@ -5,7 +5,7 @@ // Nicer focus styles // --------------------------------------- @mixin plyr-tab-focus($color: $plyr-tab-focus-default-color) { - box-shadow: 0 0 0 3px rgba($color, 0.35); + box-shadow: 0 0 0 5px rgba($color, 0.5); outline: 0; } @@ -28,6 +28,7 @@ border: 0; border-radius: ($plyr-range-track-height / 2); height: $plyr-range-track-height; + transition: box-shadow 0.3s ease; user-select: none; } @@ -36,7 +37,6 @@ border: 0; border-radius: 100%; box-shadow: $plyr-range-thumb-shadow; - box-sizing: border-box; height: $plyr-range-thumb-height; position: relative; transition: all 0.2s ease; diff --git a/src/sass/utils/hidden.scss b/src/sass/utils/hidden.scss index e4fa0aec..a42c3be8 100644 --- a/src/sass/utils/hidden.scss +++ b/src/sass/utils/hidden.scss @@ -22,3 +22,7 @@ width: 1px; } } + +.plyr [hidden] { + display: none !important; +} |