diff options
| author | Sam Potts <sam@potts.es> | 2018-08-14 00:00:24 +1000 | 
|---|---|---|
| committer | GitHub <noreply@github.com> | 2018-08-14 00:00:24 +1000 | 
| commit | 48bf36831611a854f24f8bc9f40d0944c381bd29 (patch) | |
| tree | 165ac6d7b4dbd93f9459e195eaa6c5ba2c411005 /src/js | |
| parent | a8f8486cf49aebc09c8f57bec2f8172970974536 (diff) | |
| parent | 8f94ce86a04c1b8f7cc17e7d578c6b8c76572319 (diff) | |
| download | plyr-48bf36831611a854f24f8bc9f40d0944c381bd29.tar.lz plyr-48bf36831611a854f24f8bc9f40d0944c381bd29.tar.xz plyr-48bf36831611a854f24f8bc9f40d0944c381bd29.zip | |
Merge pull request #1160 from sampotts/develop
v3.4.0
Diffstat (limited to 'src/js')
| -rw-r--r-- | src/js/captions.js | 7 | ||||
| -rw-r--r-- | src/js/config/defaults.js | 20 | ||||
| -rw-r--r-- | src/js/controls.js | 795 | ||||
| -rw-r--r-- | src/js/fullscreen.js | 4 | ||||
| -rw-r--r-- | src/js/html5.js | 3 | ||||
| -rw-r--r-- | src/js/listeners.js | 580 | ||||
| -rw-r--r-- | src/js/plugins/ads.js | 11 | ||||
| -rw-r--r-- | src/js/plugins/youtube.js | 54 | ||||
| -rw-r--r-- | src/js/plyr.js | 23 | ||||
| -rw-r--r-- | src/js/plyr.polyfilled.js | 2 | ||||
| -rw-r--r-- | src/js/ui.js | 2 | ||||
| -rw-r--r-- | src/js/utils/animation.js | 14 | ||||
| -rw-r--r-- | src/js/utils/elements.js | 51 | ||||
| -rw-r--r-- | src/js/utils/i18n.js (renamed from src/js/i18n.js) | 6 | ||||
| -rw-r--r-- | src/js/utils/is.js | 2 | 
15 files changed, 887 insertions, 687 deletions
| diff --git a/src/js/captions.js b/src/js/captions.js index 14f77a2e..ae4642aa 100644 --- a/src/js/captions.js +++ b/src/js/captions.js @@ -4,7 +4,6 @@  // ==========================================================================  import controls from './controls'; -import i18n from './i18n';  import support from './support';  import { dedupe } from './utils/arrays';  import browser from './utils/browser'; @@ -18,6 +17,7 @@ import {  } from './utils/elements';  import { on, triggerEvent } from './utils/events';  import fetch from './utils/fetch'; +import i18n from './utils/i18n';  import is from './utils/is';  import { getHTML } from './utils/strings';  import { parseUrl } from './utils/urls'; @@ -83,9 +83,8 @@ const captions = {          // * active:    The state preferred by user settings or config          // * toggled:   The real captions state -        const languages = dedupe( -            Array.from(navigator.languages || navigator.language || navigator.userLanguage).map(language => language.split('-')[0]), -        ); +        const browserLanguages = navigator.languages || [navigator.language || navigator.userLanguage || 'en']; +        const languages = dedupe(browserLanguages.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..e6e2d7c1 100644 --- a/src/js/config/defaults.js +++ b/src/js/config/defaults.js @@ -68,19 +68,7 @@ const defaults = {      // Quality default      quality: {          default: 576, -        options: [ -            4320, -            2880, -            2160, -            1440, -            1080, -            720, -            576, -            480, -            360, -            240, -            'default', // YouTube's "auto" -        ], +        options: [4320, 2880, 2160, 1440, 1080, 720, 576, 480, 360, 240],      },      // Set loops @@ -268,8 +256,9 @@ const defaults = {          // YouTube          'statechange', + +        // Quality          'qualitychange', -        'qualityrequested',          // Ads          'adsloaded', @@ -354,6 +343,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..d8de2632 100644 --- a/src/js/controls.js +++ b/src/js/controls.js @@ -1,28 +1,17 @@  // ==========================================================================  // Plyr controls +// TODO: This needs to be split into smaller files and cleaned up  // ==========================================================================  import captions from './captions';  import html5 from './html5'; -import i18n from './i18n';  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 i18n from './utils/i18n';  import is from './utils/is';  import loadSprite from './utils/loadSprite';  import { extend } from './utils/objects'; @@ -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)); @@ -270,18 +275,6 @@ const controls = {              this.elements.buttons[type] = button;          } -        // Toggle classname when pressed property is set -        const className = this.config.classNames.controlPressed; -        Object.defineProperty(button, 'pressed', { -            enumerable: true, -            get() { -                return hasClass(button, className); -            }, -            set(pressed = false) { -                toggleClass(button, className, pressed); -            }, -        }); -          return button;      }, @@ -360,7 +353,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 +365,153 @@ const controls = {          return container;      }, -    // Create a settings menu item -    createMenuItem({ value, list, type, title, badge = null, checked = false }) { -        const item = createElement('li'); +    // 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) { +        // Navigate through menus via arrow keys and space +        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(); -        const label = createElement('label', { -            class: this.config.classNames.control, +                // 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, +        ); + +        // Enter will fire a `click` event but we still need to manage focus +        // So we bind to keyup which fires after and set focus here +        on(menuItem, 'keyup', event => { +            if (event.which !== 13) { +                return; +            } + +            controls.focusFirstMenuItem.call(this, null, true);          }); +    }, -        const radio = createElement( -            'input', -            extend(getAttributesFromSelector(this.config.selectors.inputs[type]), { -                type: 'radio', -                name: `plyr-${type}`, +    // Create a settings menu item +    createMenuItem({ value, list, type, title, badge = null, checked = false }) { +        const attributes = getAttributesFromSelector(this.config.selectors.inputs[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 @@ -534,7 +643,7 @@ const controls = {          } else if (matches(range, this.config.selectors.inputs.volume)) {              const percent = range.value * 100;              range.setAttribute('aria-valuenow', percent); -            range.setAttribute('aria-valuetext', `${percent}%`); +            range.setAttribute('aria-valuetext', `${percent.toFixed(1)}%`);          } else {              range.setAttribute('aria-valuenow', range.value);          } @@ -637,7 +746,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 +775,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 +874,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 +887,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 +917,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 +966,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 +987,7 @@ const controls = {          controls.checkMenu.call(this);          // If there's no captions, bail -        if (!tracks.length) { +        if (!toggle) {              return;          } @@ -903,17 +1018,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 +1038,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 +1051,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,71 +1066,83 @@ 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);      }, +    // Focus the first menu item in a given (or visible) menu +    focusFirstMenuItem(pane, tabFocus = false) { +        if (this.elements.settings.popup.hidden) { +            return; +        } + +        let target = pane; + +        if (!is.element(target)) { +            target = Object.values(this.elements.settings.panels).find(pane => !pane.hidden); +        } + +        const firstItem = target.querySelector('[role^="menuitem"]'); + +        setFocus.call(this, firstItem, tabFocus); +    }, +      // 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; +        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); -            // 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)) { +            if (isMenuItem || (!isMenuItem && input.target !== button && show)) {                  return;              } - -            // Prevent the toggle being caught by the doc listener -            if (isButton) { -                event.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)) { +            controls.focusFirstMenuItem.call(this, null, true); +        } else if (!show && !hidden) { +            // If closing, re-focus the button +            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 +1159,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 +1179,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,29 +1206,17 @@ 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(); +        controls.focusFirstMenuItem.call(this, target, tabFocus);      },      // Build the default HTML      // TODO: Set order based on order in the config.controls array?      create(data) { -        // Do nothing if we want no controls -        if (is.empty(this.config.controls)) { -            return null; -        } -          // Create the container          const container = createElement('div', getAttributesFromSelector(this.config.selectors.controls.wrapper)); @@ -1189,36 +1284,39 @@ const controls = {              container.appendChild(controls.createTime.call(this, 'duration'));          } -        // Toggle mute button -        if (this.config.controls.includes('mute')) { -            container.appendChild(controls.createButton.call(this, 'mute')); -        } - -        // Volume range control -        if (this.config.controls.includes('volume')) { +        // Volume controls +        if (this.config.controls.includes('mute') || this.config.controls.includes('volume')) {              const volume = createElement('div', {                  class: 'plyr__volume',              }); -            // Set the attributes -            const attributes = { -                max: 1, -                step: 0.05, -                value: this.config.volume, -            }; +            // Toggle mute button +            if (this.config.controls.includes('mute')) { +                volume.appendChild(controls.createButton.call(this, 'mute')); +            } -            // Create the volume range slider -            volume.appendChild( -                controls.createRange.call( -                    this, -                    'volume', -                    extend(attributes, { -                        id: `plyr-volume-${data.id}`, -                    }), -                ), -            ); +            // Volume range control +            if (this.config.controls.includes('volume')) { +                // Set the attributes +                const attributes = { +                    max: 1, +                    step: 0.05, +                    value: this.config.volume, +                }; + +                // Create the volume range slider +                volume.appendChild( +                    controls.createRange.call( +                        this, +                        'volume', +                        extend(attributes, { +                            id: `plyr-volume-${data.id}`, +                        }), +                    ), +                ); -            this.elements.volume = volume; +                this.elements.volume = volume; +            }              container.appendChild(volume);          } @@ -1230,62 +1328,64 @@ 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,                      'aria-controls': `plyr-settings-${data.id}`,                      'aria-expanded': false,                  }),              ); -            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 +1393,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); +                }); -                const options = createElement('ul'); +                // Add to pane +                pane.appendChild(backButton); + +                // 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 @@ -1365,6 +1502,7 @@ const controls = {          this.elements.controls = container; +        // Set available quality levels          if (this.isHTML5) {              controls.setQualityMenu.call(this, html5.getQualityOptions.call(this));          } @@ -1401,13 +1539,19 @@ const controls = {          };          let update = true; -        if (is.string(this.config.controls) || is.element(this.config.controls)) { -            // String or HTMLElement passed as the option +        // If function, run it and use output +        if (is.function(this.config.controls)) { +            this.config.controls = this.config.controls.call(this.props); +        } + +        // Convert falsy controls to empty array (primarily for empty strings) +        if (!this.config.controls) { +            this.config.controls = []; +        } + +        if (is.element(this.config.controls) || is.string(this.config.controls)) { +            // HTMLElement or Non-empty string passed as the option              container = this.config.controls; -        } else if (is.function(this.config.controls)) { -            // A custom function to build controls -            // The function can return a HTMLElement or String -            container = this.config.controls.call(this, props);          } else {              // Create controls              container = controls.create.call(this, { @@ -1464,6 +1608,23 @@ const controls = {              controls.findElements.call(this);          } +        // Add pressed property to buttons +        if (!is.empty(this.elements.buttons)) { +            // Toggle classname when pressed property is set +            Object.values(this.elements.buttons).forEach(button => { +                const className = this.config.classNames.controlPressed; +                Object.defineProperty(button, 'pressed', { +                    enumerable: true, +                    get() { +                        return hasClass(button, className); +                    }, +                    set(pressed = false) { +                        toggleClass(button, className, pressed); +                    }, +                }); +            }); +        } +          // Edge sometimes doesn't finish the paint so force a redraw          if (window.navigator.userAgent.includes('Edge')) {              repaint(target); diff --git a/src/js/fullscreen.js b/src/js/fullscreen.js index 7091fde1..44c7e1cf 100644 --- a/src/js/fullscreen.js +++ b/src/js/fullscreen.js @@ -177,9 +177,7 @@ class Fullscreen {          // iOS native fullscreen doesn't need the request step          if (browser.isIos && this.player.config.fullscreen.iosNative) { -            if (this.player.playing) { -                this.target.webkitEnterFullscreen(); -            } +            this.target.webkitEnterFullscreen();          } else if (!Fullscreen.native) {              toggleFallback.call(this, true);          } else if (!this.prefix) { diff --git a/src/js/html5.js b/src/js/html5.js index 0876211a..fc8da8c0 100644 --- a/src/js/html5.js +++ b/src/js/html5.js @@ -82,6 +82,9 @@ const html5 = {                  triggerEvent.call(player, player.media, 'qualitychange', false, {                      quality: input,                  }); + +                // Save to storage +                player.storage.set({ quality: input });              },          });      }, diff --git a/src/js/listeners.js b/src/js/listeners.js index 9583bd71..5fe20695 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,61 +182,102 @@ 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') { +                if (controls && event.type === 'enterfullscreen') {                      controls.pressed = false;                      controls.hover = false;                  } @@ -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,230 @@ 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 }); -        }); - -        // Quality request -        on.call(this.player, this.player.media, 'qualityrequested', event => { -            // Save to storage -            this.player.storage.set({ quality: event.detail.quality }); +            player.storage.set({ speed: player.speed });          });          // 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 - click toggle +        this.bind(elements.buttons.settings, 'click', event => { +            // Prevent the document click listener closing the menu +            event.stopPropagation(); -        // Settings menu -        bind(this.player.elements.buttons.settings, 'click', event => { -            controls.toggleMenu.call(this.player, event); +            controls.toggleMenu.call(player, event);          }); -        // Settings menu -        bind(this.player.elements.settings.form, 'click', event => { -            event.stopPropagation(); +        // 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 => { +                const code = event.which; + +                // We only care about space and return +                if (![13, 32].includes(code)) { +                    return; +                } + +                // Because return triggers a click anyway, all we need to do is set focus +                if (code === 13) { +                    controls.focusFirstMenuItem.call(player, null, true); +                    return; +                } + +                // Prevent scroll +                event.preventDefault(); + +                // Prevent playing video (Firefox) +                event.stopPropagation(); + +                // Toggle menu +                controls.toggleMenu.call(player, event); +            }, +            null, +            false, // Can't be passive as we're preventing default +        ); -            // 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')); +        // 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 +650,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 +725,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 +747,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..375fdc13 100644 --- a/src/js/plugins/ads.js +++ b/src/js/plugins/ads.js @@ -6,9 +6,9 @@  /* global google */ -import i18n from '../i18n';  import { createElement } from '../utils/elements';  import { triggerEvent } from '../utils/events'; +import i18n from '../utils/i18n';  import is from '../utils/is';  import loadScript from '../utils/loadScript';  import { formatTime } from '../utils/time'; @@ -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/plugins/youtube.js b/src/js/plugins/youtube.js index 66a73acf..73175c14 100644 --- a/src/js/plugins/youtube.js +++ b/src/js/plugins/youtube.js @@ -2,9 +2,7 @@  // YouTube plugin  // ========================================================================== -import controls from '../controls';  import ui from '../ui'; -import { dedupe } from '../utils/arrays';  import { createElement, replaceElement, toggleClass } from '../utils/elements';  import { triggerEvent } from '../utils/events';  import fetch from '../utils/fetch'; @@ -23,37 +21,6 @@ function parseId(url) {      return url.match(regex) ? RegExp.$2 : url;  } -// Standardise YouTube quality unit -function mapQualityUnit(input) { -    const qualities = { -        hd2160: 2160, -        hd1440: 1440, -        hd1080: 1080, -        hd720: 720, -        large: 480, -        medium: 360, -        small: 240, -        tiny: 144, -    }; - -    const entry = Object.entries(qualities).find(entry => entry.includes(input)); - -    if (entry) { -        // Get the match corresponding to the input -        return entry.find(value => value !== input); -    } - -    return 'default'; -} - -function mapQualityUnits(levels) { -    if (is.empty(levels)) { -        return levels; -    } - -    return dedupe(levels.map(level => mapQualityUnit(level))); -} -  // Set playback state and trigger change (only on actual change)  function assurePlaybackState(play) {      if (play && !this.embed.hasPlayed) { @@ -225,11 +192,6 @@ const youtube = {                          triggerEvent.call(player, player.media, 'error');                      }                  }, -                onPlaybackQualityChange() { -                    triggerEvent.call(player, player.media, 'qualitychange', false, { -                        quality: player.media.quality, -                    }); -                },                  onPlaybackRateChange(event) {                      // Get the instance                      const instance = event.target; @@ -299,16 +261,6 @@ const youtube = {                          },                      }); -                    // Quality -                    Object.defineProperty(player.media, 'quality', { -                        get() { -                            return mapQualityUnit(instance.getPlaybackQuality()); -                        }, -                        set(input) { -                            instance.setPlaybackQuality(mapQualityUnit(input)); -                        }, -                    }); -                      // Volume                      let { volume } = player.config;                      Object.defineProperty(player.media, 'volume', { @@ -457,12 +409,6 @@ const youtube = {                                      player.media.duration = instance.getDuration();                                      triggerEvent.call(player, player.media, 'durationchange');                                  } - -                                // Get quality -                                controls.setQualityMenu.call( -                                    player, -                                    mapQualityUnits(instance.getAvailableQualityLevels()), -                                );                              }                              break; diff --git a/src/js/plyr.js b/src/js/plyr.js index 65b6c94d..44e41cb6 100644 --- a/src/js/plyr.js +++ b/src/js/plyr.js @@ -1,6 +1,6 @@  // ==========================================================================  // Plyr -// plyr.js v3.3.23 +// plyr.js v3.4.0-beta.2  // https://github.com/sampotts/plyr  // License: The MIT License (MIT)  // ========================================================================== @@ -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) { @@ -695,9 +698,6 @@ class Plyr {              quality = value;          } -        // Trigger request event -        triggerEvent.call(this, this.media, 'qualityrequested', false, { quality }); -          // Update config          config.selected = quality; @@ -933,13 +933,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/plyr.polyfilled.js b/src/js/plyr.polyfilled.js index 7553ee91..e9bdc14a 100644 --- a/src/js/plyr.polyfilled.js +++ b/src/js/plyr.polyfilled.js @@ -1,6 +1,6 @@  // ==========================================================================  // Plyr Polyfilled Build -// plyr.js v3.3.23 +// plyr.js v3.4.0-beta.2  // https://github.com/sampotts/plyr  // License: The MIT License (MIT)  // ========================================================================== diff --git a/src/js/ui.js b/src/js/ui.js index 34fe7e82..f0c898bf 100644 --- a/src/js/ui.js +++ b/src/js/ui.js @@ -4,11 +4,11 @@  import captions from './captions';  import controls from './controls'; -import i18n from './i18n';  import support from './support';  import browser from './utils/browser';  import { getElement, toggleClass } from './utils/elements';  import { ready, triggerEvent } from './utils/events'; +import i18n from './utils/i18n';  import is from './utils/is';  import loadImage from './utils/loadImage'; 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..a6722da2 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) { @@ -180,7 +191,7 @@ export function toggleHidden(element, hidden) {      let hide = hidden;      if (!is.boolean(hide)) { -        hide = !element.hasAttribute('hidden'); +        hide = !element.hidden;      }      if (hide) { @@ -192,6 +203,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 +217,7 @@ export function toggleClass(element, className, force) {          return element.classList.contains(className);      } -    return null; +    return false;  }  // Has class name @@ -238,19 +253,6 @@ 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)) { @@ -268,7 +270,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 @@ -283,3 +285,18 @@ export function trapFocus(element = null, 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/i18n.js b/src/js/utils/i18n.js index 5b0ebbab..f71e1a42 100644 --- a/src/js/i18n.js +++ b/src/js/utils/i18n.js @@ -2,9 +2,9 @@  // Plyr internationalization  // ========================================================================== -import is from './utils/is'; -import { getDeep } from './utils/objects'; -import { replaceAll } from './utils/strings'; +import is from './is'; +import { getDeep } from './objects'; +import { replaceAll } from './strings';  const i18n = {      get(key = '', config = {}) { 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, | 
