diff options
author | Sam Potts <sam@potts.es> | 2019-01-29 21:34:40 +1100 |
---|---|---|
committer | Sam Potts <sam@potts.es> | 2019-01-29 21:34:40 +1100 |
commit | b1da599b5d5891dc1dca44bd6aa9d8d03872fdcb (patch) | |
tree | c799fb2b444482f6d99dcdf3f16a957b290888c0 /src/js/controls.js | |
parent | afc969bac322f9b17dc0554a65fa848eb998c8e6 (diff) | |
parent | b798368ba68853558819d79a995aa0deec27f95e (diff) | |
download | plyr-b1da599b5d5891dc1dca44bd6aa9d8d03872fdcb.tar.lz plyr-b1da599b5d5891dc1dca44bd6aa9d8d03872fdcb.tar.xz plyr-b1da599b5d5891dc1dca44bd6aa9d8d03872fdcb.zip |
Merge branch 'develop' into beta
Diffstat (limited to 'src/js/controls.js')
-rw-r--r-- | src/js/controls.js | 1536 |
1 files changed, 946 insertions, 590 deletions
diff --git a/src/js/controls.js b/src/js/controls.js index ec64977e..953ca293 100644 --- a/src/js/controls.js +++ b/src/js/controls.js @@ -1,40 +1,38 @@ // ========================================================================== // 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 ui from './ui'; -import utils from './utils'; - -// Sniff out the browser -const browser = utils.getBrowser(); - +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, + 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'; +import { getPercentage, replaceAll, toCamelCase, toTitleCase } from './utils/strings'; +import { formatTime, getHours } from './utils/time'; + +// TODO: Don't export a massive object - break down and create class const controls = { - // Webkit polyfill for lower fill range - updateRangeFill(target) { - // Get range from event if event passed - const range = utils.is.event(target) ? target.target : target; - - // Needs to be a valid <input type='range'> - if (!utils.is.element(range) || range.getAttribute('type') !== 'range') { - return; - } - - // Set aria value for https://github.com/sampotts/plyr/issues/905 - range.setAttribute('aria-valuenow', range.value); - - // WebKit only - if (!browser.isWebkit) { - return; - } - - // Set CSS custom property - range.style.setProperty('--value', `${range.value / range.max * 100}%`); - }, - // Get icon URL getIconUrl() { const url = new URL(this.config.iconUrl, window.location); @@ -46,46 +44,47 @@ const controls = { }; }, - // Find the UI controls and store references in custom controls - // TODO: Allow settings menus with custom controls + // Find the UI controls findElements() { try { - this.elements.controls = utils.getElement.call(this, this.config.selectors.controls.wrapper); + this.elements.controls = getElement.call(this, this.config.selectors.controls.wrapper); // Buttons this.elements.buttons = { - play: utils.getElements.call(this, this.config.selectors.buttons.play), - pause: utils.getElement.call(this, this.config.selectors.buttons.pause), - restart: utils.getElement.call(this, this.config.selectors.buttons.restart), - rewind: utils.getElement.call(this, this.config.selectors.buttons.rewind), - fastForward: utils.getElement.call(this, this.config.selectors.buttons.fastForward), - mute: utils.getElement.call(this, this.config.selectors.buttons.mute), - pip: utils.getElement.call(this, this.config.selectors.buttons.pip), - airplay: utils.getElement.call(this, this.config.selectors.buttons.airplay), - settings: utils.getElement.call(this, this.config.selectors.buttons.settings), - captions: utils.getElement.call(this, this.config.selectors.buttons.captions), - fullscreen: utils.getElement.call(this, this.config.selectors.buttons.fullscreen), + play: getElements.call(this, this.config.selectors.buttons.play), + pause: getElement.call(this, this.config.selectors.buttons.pause), + restart: getElement.call(this, this.config.selectors.buttons.restart), + rewind: getElement.call(this, this.config.selectors.buttons.rewind), + fastForward: getElement.call(this, this.config.selectors.buttons.fastForward), + mute: getElement.call(this, this.config.selectors.buttons.mute), + pip: getElement.call(this, this.config.selectors.buttons.pip), + airplay: getElement.call(this, this.config.selectors.buttons.airplay), + settings: getElement.call(this, this.config.selectors.buttons.settings), + captions: getElement.call(this, this.config.selectors.buttons.captions), + fullscreen: getElement.call(this, this.config.selectors.buttons.fullscreen), }; // Progress - this.elements.progress = utils.getElement.call(this, this.config.selectors.progress); + this.elements.progress = getElement.call(this, this.config.selectors.progress); // Inputs this.elements.inputs = { - seek: utils.getElement.call(this, this.config.selectors.inputs.seek), - volume: utils.getElement.call(this, this.config.selectors.inputs.volume), + seek: getElement.call(this, this.config.selectors.inputs.seek), + volume: getElement.call(this, this.config.selectors.inputs.volume), }; // Display this.elements.display = { - buffer: utils.getElement.call(this, this.config.selectors.display.buffer), - currentTime: utils.getElement.call(this, this.config.selectors.display.currentTime), - duration: utils.getElement.call(this, this.config.selectors.display.duration), + buffer: getElement.call(this, this.config.selectors.display.buffer), + currentTime: getElement.call(this, this.config.selectors.display.currentTime), + duration: getElement.call(this, this.config.selectors.display.duration), }; // Seek tooltip - if (utils.is.element(this.elements.progress)) { - this.elements.display.seekTooltip = this.elements.progress.querySelector(`.${this.config.classNames.tooltip}`); + if (is.element(this.elements.progress)) { + this.elements.display.seekTooltip = this.elements.progress.querySelector( + `.${this.config.classNames.tooltip}`, + ); } return true; @@ -108,9 +107,9 @@ const controls = { // Create <svg> const icon = document.createElementNS(namespace, 'svg'); - utils.setAttributes( + setAttributes( icon, - utils.extend(attributes, { + extend(attributes, { role: 'presentation', focusable: 'false', }), @@ -125,10 +124,11 @@ const controls = { // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/xlink:href if ('href' in use) { use.setAttributeNS('http://www.w3.org/1999/xlink', 'href', path); - } else { - use.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', path); } + // Always set the older attribute even though it's "deprecated" (it'll be around for ages) + use.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', path); + // Add <use> to <svg> icon.appendChild(use); @@ -136,44 +136,28 @@ const controls = { }, // Create hidden text label - createLabel(type, attr) { - let text = i18n.get(type, this.config); - const attributes = Object.assign({}, attr); - - switch (type) { - case 'pip': - text = 'PIP'; - break; - - case 'airplay': - text = 'AirPlay'; - break; + createLabel(key, attr = {}) { + const text = i18n.get(key, this.config); - default: - break; - } - - if ('class' in attributes) { - attributes.class += ` ${this.config.classNames.hidden}`; - } else { - attributes.class = this.config.classNames.hidden; - } + const attributes = Object.assign({}, attr, { + class: [attr.class, this.config.classNames.hidden].filter(Boolean).join(' '), + }); - return utils.createElement('span', attributes, text); + return createElement('span', attributes, text); }, // Create a badge createBadge(text) { - if (utils.is.empty(text)) { + if (is.empty(text)) { return null; } - const badge = utils.createElement('span', { + const badge = createElement('span', { class: this.config.classNames.menu.value, }); badge.appendChild( - utils.createElement( + createElement( 'span', { class: this.config.classNames.menu.badge, @@ -187,22 +171,33 @@ const controls = { // Create a <button> createButton(buttonType, attr) { - const button = utils.createElement('button'); const attributes = Object.assign({}, attr); - let type = utils.toCamelCase(buttonType); + let type = toCamelCase(buttonType); - let toggle = false; - let label; - let icon; - let labelPressed; - let iconPressed; + const props = { + element: 'button', + toggle: false, + label: null, + icon: null, + labelPressed: null, + iconPressed: null, + }; - if (!('type' in attributes)) { + ['element', 'icon', 'label'].forEach(key => { + if (Object.keys(attributes).includes(key)) { + props[key] = attributes[key]; + delete attributes[key]; + } + }); + + // Default to 'button' type to prevent form submission + if (props.element === 'button' && !Object.keys(attributes).includes('type')) { attributes.type = 'button'; } - if ('class' in attributes) { - if (attributes.class.includes(this.config.classNames.control)) { + // Set class name + if (Object.keys(attributes).includes('class')) { + if (!attributes.class.includes(this.config.classNames.control)) { attributes.class += ` ${this.config.classNames.control}`; } } else { @@ -212,74 +207,92 @@ const controls = { // Large play button switch (buttonType) { case 'play': - toggle = true; - label = 'play'; - labelPressed = 'pause'; - icon = 'play'; - iconPressed = 'pause'; + props.toggle = true; + props.label = 'play'; + props.labelPressed = 'pause'; + props.icon = 'play'; + props.iconPressed = 'pause'; break; case 'mute': - toggle = true; - label = 'mute'; - labelPressed = 'unmute'; - icon = 'volume'; - iconPressed = 'muted'; + props.toggle = true; + props.label = 'mute'; + props.labelPressed = 'unmute'; + props.icon = 'volume'; + props.iconPressed = 'muted'; break; case 'captions': - toggle = true; - label = 'enableCaptions'; - labelPressed = 'disableCaptions'; - icon = 'captions-off'; - iconPressed = 'captions-on'; + props.toggle = true; + props.label = 'enableCaptions'; + props.labelPressed = 'disableCaptions'; + props.icon = 'captions-off'; + props.iconPressed = 'captions-on'; break; case 'fullscreen': - toggle = true; - label = 'enterFullscreen'; - labelPressed = 'exitFullscreen'; - icon = 'enter-fullscreen'; - iconPressed = 'exit-fullscreen'; + props.toggle = true; + props.label = 'enterFullscreen'; + props.labelPressed = 'exitFullscreen'; + props.icon = 'enter-fullscreen'; + props.iconPressed = 'exit-fullscreen'; break; case 'play-large': attributes.class += ` ${this.config.classNames.control}--overlaid`; type = 'play'; - label = 'play'; - icon = 'play'; + props.label = 'play'; + props.icon = 'play'; break; default: - label = type; - icon = buttonType; + if (is.empty(props.label)) { + props.label = type; + } + if (is.empty(props.icon)) { + props.icon = buttonType; + } } + const button = createElement(props.element); + // Setup toggle icon and labels - if (toggle) { + if (props.toggle) { // Icon - button.appendChild(controls.createIcon.call(this, iconPressed, { class: 'icon--pressed' })); - button.appendChild(controls.createIcon.call(this, icon, { class: 'icon--not-pressed' })); + button.appendChild( + controls.createIcon.call(this, props.iconPressed, { + class: 'icon--pressed', + }), + ); + button.appendChild( + controls.createIcon.call(this, props.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' })); - - // Add aria attributes - attributes['aria-pressed'] = false; + button.appendChild( + controls.createLabel.call(this, props.labelPressed, { + class: 'label--pressed', + }), + ); + button.appendChild( + controls.createLabel.call(this, props.label, { + class: 'label--not-pressed', + }), + ); } else { - button.appendChild(controls.createIcon.call(this, icon)); - button.appendChild(controls.createLabel.call(this, label)); + button.appendChild(controls.createIcon.call(this, props.icon)); + button.appendChild(controls.createLabel.call(this, props.label)); } - // Merge attributes - utils.extend(attributes, utils.getAttributesFromSelector(this.config.selectors.buttons[type], attributes)); - - utils.setAttributes(button, attributes); + // Merge and set attributes + extend(attributes, getAttributesFromSelector(this.config.selectors.buttons[type], attributes)); + setAttributes(button, attributes); // We have multiple play buttons if (type === 'play') { - if (!utils.is.array(this.elements.buttons[type])) { + if (!is.array(this.elements.buttons[type])) { this.elements.buttons[type] = []; } @@ -293,22 +306,11 @@ const controls = { // Create an <input type='range'> createRange(type, attributes) { - // Seek label - const label = utils.createElement( - 'label', - { - for: attributes.id, - id: `${attributes.id}-label`, - class: this.config.classNames.hidden, - }, - i18n.get(type, this.config), - ); - // Seek input - const input = utils.createElement( + const input = createElement( 'input', - utils.extend( - utils.getAttributesFromSelector(this.config.selectors.inputs[type]), + extend( + getAttributesFromSelector(this.config.selectors.inputs[type]), { type: 'range', min: 0, @@ -318,7 +320,7 @@ const controls = { autocomplete: 'off', // A11y fixes for https://github.com/sampotts/plyr/issues/905 role: 'slider', - 'aria-labelledby': `${attributes.id}-label`, + 'aria-label': i18n.get(type, this.config), 'aria-valuemin': 0, 'aria-valuemax': 100, 'aria-valuenow': 0, @@ -332,18 +334,15 @@ const controls = { // Set the fill for webkit now controls.updateRangeFill.call(this, input); - return { - label, - input, - }; + return input; }, // Create a <progress> createProgress(type, attributes) { - const progress = utils.createElement( + const progress = createElement( 'progress', - utils.extend( - utils.getAttributesFromSelector(this.config.selectors.display[type]), + extend( + getAttributesFromSelector(this.config.selectors.display[type]), { min: 0, max: 100, @@ -357,23 +356,15 @@ const controls = { // Create the label inside if (type !== 'volume') { - progress.appendChild(utils.createElement('span', null, '0')); - - let suffix = ''; - switch (type) { - case 'played': - suffix = i18n.get('played', this.config); - break; - - case 'buffer': - suffix = i18n.get('buffered', this.config); - break; + progress.appendChild(createElement('span', null, '0')); - default: - break; - } + const suffixKey = { + played: 'played', + buffer: 'buffered', + }[type]; + const suffix = suffixKey ? i18n.get(suffixKey, this.config) : ''; - progress.textContent = `% ${suffix.toLowerCase()}`; + progress.innerText = `% ${suffix.toLowerCase()}`; } this.elements.display[type] = progress; @@ -383,12 +374,16 @@ const controls = { // Create time display createTime(type) { - const attributes = utils.getAttributesFromSelector(this.config.selectors.display[type]); + const attributes = getAttributesFromSelector(this.config.selectors.display[type]); - const container = utils.createElement('div', utils.extend(attributes, { - class: `plyr__time ${attributes.class}`, - 'aria-label': i18n.get(type, this.config), - }), '00:00'); + const container = createElement( + 'div', + extend(attributes, { + class: `${this.config.classNames.display.time} ${attributes.class ? attributes.class : ''}`.trim(), + 'aria-label': i18n.get(type, this.config), + }), + '00:00', + ); // Reference for updates this.elements.display[type] = container; @@ -396,37 +391,296 @@ const controls = { return container; }, - // Create a settings menu item - createMenuItem(value, list, type, title, badge = null, checked = false) { - const item = utils.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(); + + // We're just here to prevent the keydown bubbling + if (event.type === 'keydown') { + return; + } + + const isRadioButton = matches(menuItem, '[role="menuitemradio"]'); - const label = utils.createElement('label', { - class: this.config.classNames.control, + // 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 = utils.createElement( - 'input', - utils.extend(utils.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 = utils.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 (utils.is.element(badge)) { - label.appendChild(badge); + if (is.element(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 + formatTime(time = 0, inverted = false) { + // Bail if the value isn't a number + if (!is.number(time)) { + return time; + } + + // Always display hours if duration is over an hour + const forceHours = getHours(this.duration) > 0; + + return formatTime(time, forceHours, inverted); + }, + + // Update the displayed time + updateTimeDisplay(target = null, time = 0, inverted = false) { + // Bail if there's no element to display or the value isn't a number + if (!is.element(target) || !is.number(time)) { + return; + } + + // eslint-disable-next-line no-param-reassign + target.innerText = controls.formatTime(time, inverted); + }, + + // Update volume UI and storage + updateVolume() { + if (!this.supported.ui) { + return; + } + + // Update range + if (is.element(this.elements.inputs.volume)) { + controls.setRange.call(this, this.elements.inputs.volume, this.muted ? 0 : this.volume); + } + + // Update mute state + if (is.element(this.elements.buttons.mute)) { + this.elements.buttons.mute.pressed = this.muted || this.volume === 0; + } + }, + + // Update seek value and lower fill + setRange(target, value = 0) { + if (!is.element(target)) { + return; + } + + // eslint-disable-next-line + target.value = value; + + // Webkit range fill + controls.updateRangeFill.call(this, target); + }, + + // Update <progress> elements + updateProgress(event) { + if (!this.supported.ui || !is.event(event)) { + return; + } + + let value = 0; + + const setProgress = (target, input) => { + const value = is.number(input) ? input : 0; + const progress = is.element(target) ? target : this.elements.display.buffer; + + // Update value and label + if (is.element(progress)) { + progress.value = value; + + // Update text label inside + const label = progress.getElementsByTagName('span')[0]; + if (is.element(label)) { + label.childNodes[0].nodeValue = value; + } + } + }; + + if (event) { + switch (event.type) { + // Video playing + case 'timeupdate': + case 'seeking': + case 'seeked': + value = getPercentage(this.currentTime, this.duration); + + // Set seek range value only if it's a 'natural' time event + if (event.type === 'timeupdate') { + controls.setRange.call(this, this.elements.inputs.seek, value); + } + + break; + + // Check buffer status + case 'playing': + case 'progress': + setProgress(this.elements.display.buffer, this.buffered * 100); + + break; + + default: + break; + } + } + }, + + // Webkit polyfill for lower fill range + updateRangeFill(target) { + // Get range from event if event passed + const range = is.event(target) ? target.target : target; + + // Needs to be a valid <input type='range'> + if (!is.element(range) || range.getAttribute('type') !== 'range') { + return; + } + + // Set aria values for https://github.com/sampotts/plyr/issues/905 + if (matches(range, this.config.selectors.inputs.seek)) { + range.setAttribute('aria-valuenow', this.currentTime); + const currentTime = controls.formatTime(this.currentTime); + const duration = controls.formatTime(this.duration); + const format = i18n.get('seekLabel', this.config); + range.setAttribute( + 'aria-valuetext', + format.replace('{currentTime}', currentTime).replace('{duration}', duration), + ); + } else if (matches(range, this.config.selectors.inputs.volume)) { + const percent = range.value * 100; + range.setAttribute('aria-valuenow', percent); + range.setAttribute('aria-valuetext', `${percent.toFixed(1)}%`); + } else { + range.setAttribute('aria-valuenow', range.value); + } + + // WebKit only + if (!browser.isWebkit) { + return; + } + + // Set CSS custom property + range.style.setProperty('--value', `${(range.value / range.max) * 100}%`); }, // Update hover tooltip for seeking @@ -434,8 +688,8 @@ const controls = { // Bail if setting not true if ( !this.config.tooltips.seek || - !utils.is.element(this.elements.inputs.seek) || - !utils.is.element(this.elements.display.seekTooltip) || + !is.element(this.elements.inputs.seek) || + !is.element(this.elements.display.seekTooltip) || this.duration === 0 ) { return; @@ -443,11 +697,11 @@ const controls = { // Calculate percentage let percent = 0; - const clientRect = this.elements.inputs.seek.getBoundingClientRect(); + const clientRect = this.elements.progress.getBoundingClientRect(); const visible = `${this.config.classNames.tooltip}--visible`; const toggle = toggle => { - utils.toggleClass(this.elements.display.seekTooltip, visible, toggle); + toggleClass(this.elements.display.seekTooltip, visible, toggle); }; // Hide on touch @@ -457,9 +711,9 @@ const controls = { } // Determine percentage, if already visible - if (utils.is.event(event)) { - percent = 100 / clientRect.width * (event.pageX - clientRect.left); - } else if (utils.hasClass(this.elements.display.seekTooltip, visible)) { + if (is.event(event)) { + percent = (100 / clientRect.width) * (event.pageX - clientRect.left); + } else if (hasClass(this.elements.display.seekTooltip, visible)) { percent = parseFloat(this.elements.display.seekTooltip.style.left, 10); } else { return; @@ -473,115 +727,153 @@ const controls = { } // Display the time a click would seek to - ui.updateTimeDisplay.call(this, this.elements.display.seekTooltip, this.duration / 100 * percent); + controls.updateTimeDisplay.call(this, this.elements.display.seekTooltip, (this.duration / 100) * percent); // Set position this.elements.display.seekTooltip.style.left = `${percent}%`; // Show/hide the tooltip // If the event is a moues in/out and percentage is inside bounds - if (utils.is.event(event) && [ - 'mouseenter', - 'mouseleave', - ].includes(event.type)) { + if (is.event(event) && ['mouseenter', 'mouseleave'].includes(event.type)) { toggle(event.type === 'mouseenter'); } }, - // Hide/show a tab - toggleTab(setting, toggle) { - utils.toggleHidden(this.elements.settings.tabs[setting], !toggle); + // Handle time change event + timeUpdate(event) { + // Only invert if only one time element is displayed and used for both duration and currentTime + const invert = !is.element(this.elements.display.duration) && this.config.invertTime; + + // Duration + controls.updateTimeDisplay.call( + this, + this.elements.display.currentTime, + invert ? this.duration - this.currentTime : this.currentTime, + invert, + ); + + // Ignore updates while seeking + if (event && event.type === 'timeupdate' && this.media.seeking) { + return; + } + + // Playing progress + controls.updateProgress.call(this, event); }, - // Set the quality menu - // TODO: Vimeo support - setQualityMenu(options) { - // Menu required - if (!utils.is.element(this.elements.settings.panes.quality)) { + // Show the duration on metadataloaded or durationchange events + durationUpdate() { + // Bail if no UI or durationchange event triggered after playing/seek when invertTime is false + if (!this.supported.ui || (!this.config.invertTime && this.currentTime)) { return; } - const type = 'quality'; - const list = this.elements.settings.panes.quality.querySelector('ul'); + // If duration is the 2**32 (shaka), Infinity (HLS), DASH-IF (Number.MAX_SAFE_INTEGER || Number.MAX_VALUE) indicating live we hide the currentTime and progressbar. + // 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) { + toggleHidden(this.elements.display.currentTime, true); + toggleHidden(this.elements.progress, true); + return; + } - // Set options if passed and filter based on config - if (utils.is.array(options)) { - this.options.quality = options.filter(quality => this.config.quality.options.includes(quality)); + // Update ARIA values + if (is.element(this.elements.inputs.seek)) { + this.elements.inputs.seek.setAttribute('aria-valuemax', this.duration); } - // Toggle the pane and tab - const toggle = !utils.is.empty(this.options.quality) && this.options.quality.length > 1; - controls.toggleTab.call(this, type, toggle); + // If there's a spot to display duration + const hasDuration = is.element(this.elements.display.duration); - // Check if we need to toggle the parent - controls.checkMenu.call(this); + // If there's only one time display, display duration there + if (!hasDuration && this.config.displayDuration && this.paused) { + controls.updateTimeDisplay.call(this, this.elements.display.currentTime, this.duration); + } - // If we're hiding, nothing more to do - if (!toggle) { - return; + // If there's a duration element, update content + if (hasDuration) { + controls.updateTimeDisplay.call(this, this.elements.display.duration, this.duration); } - // Empty the menu - utils.emptyElement(list); + // Update the tooltip (if visible) + controls.updateSeekTooltip.call(this); + }, - // Get the badge HTML for HD, 4K etc - const getBadge = quality => { - let label = ''; + // Hide/show a tab + toggleMenuButton(setting, toggle) { + toggleHidden(this.elements.settings.buttons[setting], !toggle); + }, - switch (quality) { - case 2160: - label = '4K'; - break; + // Update the selected setting + updateSetting(setting, container, input) { + const pane = this.elements.settings.panels[setting]; + let value = null; + let list = container; - case 1440: - case 1080: - case 720: - label = 'HD'; - break; + if (setting === 'captions') { + value = this.currentTrack; + } else { + value = !is.empty(input) ? input : this[setting]; - case 576: - case 480: - label = 'SD'; - break; + // Get default + if (is.empty(value)) { + value = this.config[setting].default; + } - default: - break; + // Unsupported value + if (!is.empty(this.options[setting]) && !this.options[setting].includes(value)) { + this.debug.warn(`Unsupported value of '${value}' for ${setting}`); + return; } - if (!label.length) { - return null; + // Disabled value + if (!this.config[setting].options.includes(value)) { + this.debug.warn(`Disabled value of '${value}' for ${setting}`); + return; } + } - return controls.createBadge.call(this, label); - }; + // Get the list if we need to + if (!is.element(list)) { + list = pane && pane.querySelector('[role="menu"]'); + } - // Sort options by the config and then render options - this.options.quality - .sort((a, b) => { - const sorting = this.config.quality.options; - return sorting.indexOf(a) > sorting.indexOf(b) ? 1 : -1; - }) - .forEach(quality => { - const label = controls.getLabel.call(this, 'quality', quality); - controls.createMenuItem.call(this, quality, list, type, label, getBadge(quality)); - }); + // If there's no list it means it's not been rendered... + if (!is.element(list)) { + return; + } - controls.updateSetting.call(this, type, list); + // 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 - // TODO: Localisation getLabel(setting, value) { switch (setting) { case 'speed': return value === 1 ? i18n.get('normal', this.config) : `${value}×`; case 'quality': - if (utils.is.number(value)) { - return `${value}p`; + if (is.number(value)) { + const label = i18n.get(`qualityLabel.${value}`, this.config); + + if (!label.length) { + return `${value}p`; + } + + return label; } - return utils.toTitleCase(value); + return toTitleCase(value); case 'captions': return captions.getLabel.call(this); @@ -591,98 +883,93 @@ const controls = { } }, - // Update the selected setting - updateSetting(setting, container, input) { - const pane = this.elements.settings.panes[setting]; - let value = null; - let list = container; - - switch (setting) { - case 'captions': - if (this.captions.active) { - if (this.options.captions.length > 2 || !this.options.captions.some(lang => lang === 'enabled')) { - value = this.captions.language; - } else { - value = 'enabled'; - } - } else { - value = ''; - } + // Set the quality menu + setQualityMenu(options) { + // Menu required + if (!is.element(this.elements.settings.panels.quality)) { + return; + } - break; + const type = 'quality'; + const list = this.elements.settings.panels.quality.querySelector('[role="menu"]'); - default: - value = !utils.is.empty(input) ? input : this[setting]; + // Set options if passed and filter based on uniqueness and config + if (is.array(options)) { + this.options.quality = dedupe(options).filter(quality => this.config.quality.options.includes(quality)); + } - // Get default - if (utils.is.empty(value)) { - value = this.config[setting].default; - } + // Toggle the pane and tab + const toggle = !is.empty(this.options.quality) && this.options.quality.length > 1; + controls.toggleMenuButton.call(this, type, toggle); - // Unsupported value - if (!utils.is.empty(this.options[setting]) && !this.options[setting].includes(value)) { - this.debug.warn(`Unsupported value of '${value}' for ${setting}`); - return; - } + // Empty the menu + emptyElement(list); - // Disabled value - if (!this.config[setting].options.includes(value)) { - this.debug.warn(`Disabled value of '${value}' for ${setting}`); - return; - } + // Check if we need to toggle the parent + controls.checkMenu.call(this); - break; + // If we're hiding, nothing more to do + if (!toggle) { + return; } - // Get the list if we need to - if (!utils.is.element(list)) { - list = pane && pane.querySelector('ul'); - } + // Get the badge HTML for HD, 4K etc + const getBadge = quality => { + const label = i18n.get(`qualityBadge.${quality}`, this.config); - // If there's no list it means it's not been rendered... - if (!utils.is.element(list)) { - return; - } + if (!label.length) { + return null; + } - // Update the label - const label = this.elements.settings.tabs[setting].querySelector(`.${this.config.classNames.menu.value}`); - label.innerHTML = controls.getLabel.call(this, setting, value); + return controls.createBadge.call(this, label); + }; - // Find the radio option and check it - const target = list && list.querySelector(`input[value="${value}"]`); + // Sort options by the config and then render options + this.options.quality + .sort((a, b) => { + const sorting = this.config.quality.options; + return sorting.indexOf(a) > sorting.indexOf(b) ? 1 : -1; + }) + .forEach(quality => { + controls.createMenuItem.call(this, { + value: quality, + list, + type, + title: controls.getLabel.call(this, 'quality', quality), + badge: getBadge(quality), + }); + }); - if (utils.is.element(target)) { - target.checked = true; - } + controls.updateSetting.call(this, type, list); }, // Set the looping options /* setLoopMenu() { // Menu required - if (!utils.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 - utils.toggleHidden(this.elements.settings.tabs.loop, false); - utils.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 = !utils.is.empty(this.loop.options); - controls.toggleTab.call(this, 'loop', toggle); + const toggle = !is.empty(this.loop.options); + controls.toggleMenuButton.call(this, 'loop', toggle); // Empty the menu - utils.emptyElement(list); + emptyElement(list); options.forEach(option => { - const item = utils.createElement('li'); + const item = createElement('li'); - const button = utils.createElement( + const button = createElement( 'button', - utils.extend(utils.getAttributesFromSelector(this.config.selectors.buttons.loop), { + extend(getAttributesFromSelector(this.config.selectors.buttons.loop), { type: 'button', class: this.config.classNames.control, 'data-plyr-loop-action': option, @@ -705,16 +992,22 @@ 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 - const toggle = captions.getTracks.call(this).length; - controls.toggleTab.call(this, type, toggle); + controls.toggleMenuButton.call(this, type, toggle); // Empty the menu - utils.emptyElement(list); + emptyElement(list); // Check if we need to toggle the parent controls.checkMenu.call(this); @@ -724,72 +1017,57 @@ const controls = { return; } - // Re-map the tracks into just the data we need - const tracks = captions.getTracks.call(this).map(track => ({ - language: !utils.is.empty(track.language) ? track.language : 'enabled', - label: captions.getLabel.call(this, track), + // Generate options data + const options = tracks.map((track, value) => ({ + value, + checked: this.captions.toggled && this.currentTrack === value, + title: captions.getLabel.call(this, track), + badge: track.language && controls.createBadge.call(this, track.language.toUpperCase()), + list, + type: 'language', })); // Add the "Disabled" option to turn off captions - tracks.unshift({ - language: '', - label: i18n.get('disabled', this.config), + options.unshift({ + value: -1, + checked: !this.captions.toggled, + title: i18n.get('disabled', this.config), + list, + type: 'language', }); // Generate options - tracks.forEach(track => { - controls.createMenuItem.call( - this, - track.language, - list, - 'language', - track.label, - track.language !== 'enabled' ? controls.createBadge.call(this, track.language.toUpperCase()) : null, - track.language.toLowerCase() === this.captions.language.toLowerCase(), - ); - }); - - // Store reference - this.options.captions = tracks.map(track => track.language); + options.forEach(controls.createMenuItem.bind(this)); controls.updateSetting.call(this, type, list); }, // 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 (!utils.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 (utils.is.array(options)) { + if (is.array(options)) { this.options.speed = options; } else if (this.isHTML5 || this.isVimeo) { - this.options.speed = [ - 0.5, - 0.75, - 1, - 1.25, - 1.5, - 1.75, - 2, - ]; + this.options.speed = [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2]; } // Set options if passed and filter based on config this.options.speed = this.options.speed.filter(speed => this.config.speed.options.includes(speed)); // Toggle the pane and tab - const toggle = !utils.is.empty(this.options.speed) && this.options.speed.length > 1; - controls.toggleTab.call(this, type, toggle); + const toggle = !is.empty(this.options.speed) && this.options.speed.length > 1; + controls.toggleMenuButton.call(this, type, toggle); + + // Empty the menu + emptyElement(list); // Check if we need to toggle the parent controls.checkMenu.call(this); @@ -799,16 +1077,14 @@ const controls = { return; } - // Get the list to populate - const list = this.elements.settings.panes.speed.querySelector('ul'); - - // Empty the menu - utils.emptyElement(list); - // Create items this.options.speed.forEach(speed => { - const label = controls.getLabel.call(this, 'speed', speed); - controls.createMenuItem.call(this, speed, list, type, label); + controls.createMenuItem.call(this, { + value: speed, + list, + type, + title: controls.getLabel.call(this, 'speed', speed), + }); }); controls.updateSetting.call(this, type, list); @@ -816,71 +1092,83 @@ const controls = { // Check if we need to hide/show the settings menu checkMenu() { - const { tabs } = this.elements.settings; - const visible = !utils.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); - utils.toggleHidden(this.elements.settings.menu, !visible); + 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 (!utils.is.element(form) || !utils.is.element(button)) { + if (!is.element(popup) || !is.element(button)) { return; } - const show = utils.is.boolean(event) ? event : utils.is.element(form) && form.hasAttribute('hidden'); + // True toggle by default + const { hidden } = popup; + let show = hidden; - if (utils.is.event(event)) { - const isMenuItem = utils.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 (utils.is.element(button)) { - button.setAttribute('aria-expanded', show); - } + // Set button attributes + button.setAttribute('aria-expanded', show); - if (utils.is.element(form)) { - utils.toggleHidden(form, !show); - utils.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); @@ -889,7 +1177,7 @@ const controls = { const height = clone.scrollHeight; // Remove from the DOM - utils.removeElement(clone); + removeElement(clone); return { width, @@ -897,31 +1185,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 (!utils.is.element(pane)) { - return; - } - - // Are we targetting 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) { @@ -930,15 +1205,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; } @@ -947,11 +1219,11 @@ const controls = { container.style.height = ''; // Only listen once - utils.off(container, utils.transitionEndEvent, restore); + off.call(this, container, transitionEndEvent, restore); }; // Listen for the transition finishing and restore auto height/width - utils.on(container, utils.transitionEndEvent, restore); + on.call(this, container, transitionEndEvent, restore); // Set dimensions to target container.style.width = `${size.width}px`; @@ -959,32 +1231,33 @@ const controls = { } // Set attributes on current tab - utils.toggleHidden(current, true); - current.setAttribute('tabindex', -1); + toggleHidden(current, true); // Set attributes on target - utils.toggleHidden(pane, false); - - const tabs = utils.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); + }, + + // Set the download link + setDownloadLink() { + const button = this.elements.buttons.download; + + // Bail if no button + if (!is.element(button)) { + return; + } + + // Set download link + button.setAttribute('href', this.download); }, // Build the default HTML // TODO: Set order based on order in the config.controls array? create(data) { - // Do nothing if we want no controls - if (utils.is.empty(this.config.controls)) { - return null; - } - // Create the container - const container = utils.createElement('div', utils.getAttributesFromSelector(this.config.selectors.controls.wrapper)); + const container = createElement('div', getAttributesFromSelector(this.config.selectors.controls.wrapper)); // Restart button if (this.config.controls.includes('restart')) { @@ -1008,14 +1281,14 @@ const controls = { // Progress if (this.config.controls.includes('progress')) { - const progress = utils.createElement('div', utils.getAttributesFromSelector(this.config.selectors.progress)); + const progress = createElement('div', getAttributesFromSelector(this.config.selectors.progress)); // Seek range slider - const seek = controls.createRange.call(this, 'seek', { - id: `plyr-seek-${data.id}`, - }); - progress.appendChild(seek.label); - progress.appendChild(seek.input); + progress.appendChild( + controls.createRange.call(this, 'seek', { + id: `plyr-seek-${data.id}`, + }), + ); // Buffer progress progress.appendChild(controls.createProgress.call(this, 'buffer')); @@ -1024,10 +1297,9 @@ const controls = { // Seek tooltip if (this.config.tooltips.seek) { - const tooltip = utils.createElement( + const tooltip = createElement( 'span', { - role: 'tooltip', class: this.config.classNames.tooltip, }, '00:00', @@ -1051,36 +1323,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')) { - const volume = utils.createElement('div', { + // 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 - const range = controls.createRange.call( - this, - 'volume', - utils.extend(attributes, { - id: `plyr-volume-${data.id}`, - }), - ); - volume.appendChild(range.label); - volume.appendChild(range.input); + // 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); } @@ -1091,118 +1366,157 @@ const controls = { } // Settings button / menu - if (this.config.controls.includes('settings') && !utils.is.empty(this.config.settings)) { - const menu = utils.createElement('div', { + if (this.config.controls.includes('settings') && !is.empty(this.config.settings)) { + 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 = utils.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 = utils.createElement('div'); + const inner = createElement('div'); - const home = utils.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 = utils.createElement('ul', { - role: 'tablist', + // Create the menu + const menu = createElement('div', { + role: 'menu', }); - // Build the tabs - this.config.settings.forEach(type => { - const tab = utils.createElement('li', { - role: 'tab', - hidden: '', - }); + home.appendChild(menu); + inner.appendChild(home); + this.elements.settings.panels.home = home; - const button = utils.createElement( + // Build the menu items + this.config.settings.forEach(type => { + // TODO: bundle this with the createMenuItem helper and bindings + const menuItem = createElement( 'button', - utils.extend(utils.getAttributesFromSelector(this.config.selectors.buttons.settings), { + 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), ); - const value = utils.createElement('span', { + // 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, }); // Speed contains HTML entities value.innerHTML = data[type]; - button.appendChild(value); - tab.appendChild(button); - tabs.appendChild(tab); + flex.appendChild(value); + menuItem.appendChild(flex); + menu.appendChild(menuItem); - this.elements.settings.tabs[type] = tab; - }); - - home.appendChild(tabs); - inner.appendChild(home); - - // Build the panes - this.config.settings.forEach(type => { - const pane = utils.createElement('div', { + // 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 = utils.createElement( - 'button', - { - type: 'button', - class: `${this.config.classNames.control} ${this.config.classNames.control}--back`, - 'aria-haspopup': true, - 'aria-controls': `plyr-settings-${data.id}-home`, - 'aria-expanded': false, + // Back button + const backButton = createElement('button', { + type: 'button', + class: `${this.config.classNames.control} ${this.config.classNames.control}--back`, + }); + + // Visible label + backButton.appendChild( + createElement( + 'span', + { + 'aria-hidden': true, + }, + i18n.get(type, this.config), + ), + ); + + // Screen reader label + backButton.appendChild( + createElement( + 'span', + { + class: this.config.classNames.hidden, + }, + i18n.get('menuBack', this.config), + ), + ); + + // Go back via keyboard + on( + pane, + 'keydown', + event => { + // We only care about <- + if (event.which !== 37) { + return; + } + + // Prevent seek + event.preventDefault(); + event.stopPropagation(); + + // Show the respective menu + controls.showMenuPanel.call(this, 'home', true); }, - i18n.get(type, this.config), + false, ); - pane.appendChild(back); + // Go back via button click + on(backButton, 'click', () => { + controls.showMenuPanel.call(this, 'home', false); + }); + + // Add to pane + pane.appendChild(backButton); - const options = utils.createElement('ul'); + // Menu + pane.appendChild( + createElement('div', { + role: 'menu', + }), + ); - pane.appendChild(options); inner.appendChild(pane); - this.elements.settings.panes[type] = pane; + this.elements.settings.buttons[type] = menuItem; + this.elements.settings.panels[type] = pane; }); - form.appendChild(inner); - menu.appendChild(form); - container.appendChild(menu); + popup.appendChild(inner); + control.appendChild(popup); + container.appendChild(control); - this.elements.settings.form = form; - this.elements.settings.menu = menu; + this.elements.settings.popup = popup; + this.elements.settings.menu = control; } // Picture in picture button @@ -1215,6 +1529,26 @@ const controls = { container.appendChild(controls.createButton.call(this, 'airplay')); } + // Download button + if (this.config.controls.includes('download')) { + const attributes = { + element: 'a', + href: this.download, + target: '_blank', + }; + + const { download } = this.config.urls; + + if (!is.url(download) && this.isEmbed) { + extend(attributes, { + icon: `logo-${this.provider}`, + label: this.provider, + }); + } + + container.appendChild(controls.createButton.call(this, 'download', attributes)); + } + // Toggle fullscreen button if (this.config.controls.includes('fullscreen')) { container.appendChild(controls.createButton.call(this, 'fullscreen')); @@ -1227,6 +1561,7 @@ const controls = { this.elements.controls = container; + // Set available quality levels if (this.isHTML5) { controls.setQualityMenu.call(this, html5.getQualityOptions.call(this)); } @@ -1244,7 +1579,7 @@ const controls = { // Only load external sprite using AJAX if (icon.cors) { - utils.loadSprite(icon.url, 'sprite-plyr'); + loadSprite(icon.url, 'sprite-plyr'); } } @@ -1263,13 +1598,19 @@ const controls = { }; let update = true; - if (utils.is.string(this.config.controls) || utils.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 (utils.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, { @@ -1288,11 +1629,8 @@ const controls = { const replace = input => { let result = input; - Object.entries(props).forEach(([ - key, - value, - ]) => { - result = utils.replaceAll(result, `{${key}}`, value); + Object.entries(props).forEach(([key, value]) => { + result = replaceAll(result, `{${key}}`, value); }); return result; @@ -1300,9 +1638,9 @@ const controls = { // Update markup if (update) { - if (utils.is.string(this.config.controls)) { + if (is.string(this.config.controls)) { container = replace(container); - } else if (utils.is.element(container)) { + } else if (is.element(container)) { container.innerHTML = replace(container.innerHTML); } } @@ -1311,49 +1649,67 @@ const controls = { let target; // Inject to custom location - if (utils.is.string(this.config.selectors.controls.container)) { + if (is.string(this.config.selectors.controls.container)) { target = document.querySelector(this.config.selectors.controls.container); } // Inject into the container by default - if (!utils.is.element(target)) { + if (!is.element(target)) { target = this.elements.container; } - // Inject controls HTML - if (utils.is.element(container)) { - target.appendChild(container); - } else if (container) { - target.insertAdjacentHTML('beforeend', container); - } + // Inject controls HTML (needs to be before captions, hence "afterbegin") + const insertMethod = is.element(container) ? 'insertAdjacentElement' : 'insertAdjacentHTML'; + target[insertMethod]('afterbegin', container); // Find the elements if need be - if (!utils.is.element(this.elements.controls)) { + if (!is.element(this.elements.controls)) { controls.findElements.call(this); } - // Edge sometimes doesn't finish the paint so force a redraw - if (window.navigator.userAgent.includes('Edge')) { - utils.repaint(target); + // Add pressed property to buttons + if (!is.empty(this.elements.buttons)) { + const addProperty = 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); + }, + }); + }; + + // Toggle classname when pressed property is set + Object.values(this.elements.buttons) + .filter(Boolean) + .forEach(button => { + if (is.array(button) || is.nodeList(button)) { + Array.from(button) + .filter(Boolean) + .forEach(addProperty); + } else { + addProperty(button); + } + }); + } + + // Edge sometimes doesn't finish the paint so force a repaint + if (browser.isEdge) { + repaint(target); } // Setup tooltips if (this.config.tooltips.controls) { - const labels = utils.getElements.call( - this, - [ - this.config.selectors.controls.wrapper, - ' ', - this.config.selectors.labels, - ' .', - this.config.classNames.hidden, - ].join(''), - ); + const { classNames, selectors } = this.config; + const selector = `${selectors.controls.wrapper} ${selectors.labels} .${classNames.hidden}`; + const labels = getElements.call(this, selector); Array.from(labels).forEach(label => { - utils.toggleClass(label, this.config.classNames.hidden, false); - utils.toggleClass(label, this.config.classNames.tooltip, true); - label.setAttribute('role', 'tooltip'); + toggleClass(label, this.config.classNames.hidden, false); + toggleClass(label, this.config.classNames.tooltip, true); }); } }, |