diff options
Diffstat (limited to 'src/js/controls.js')
-rw-r--r-- | src/js/controls.js | 3242 |
1 files changed, 1619 insertions, 1623 deletions
diff --git a/src/js/controls.js b/src/js/controls.js index 37df497f..ad126de1 100644 --- a/src/js/controls.js +++ b/src/js/controls.js @@ -12,18 +12,18 @@ 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, + 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'; @@ -35,917 +35,915 @@ import { formatTime, getHours } from './utils/time'; // TODO: Don't export a massive object - break down and create class const controls = { - // Get icon URL - getIconUrl() { - const url = new URL(this.config.iconUrl, window.location); - const cors = url.host !== window.location.host || (browser.isIE && !window.svg4everybody); - - return { - url: this.config.iconUrl, - cors, - }; - }, - - // Find the UI controls - findElements() { - try { - this.elements.controls = getElement.call(this, this.config.selectors.controls.wrapper); - - // Buttons - this.elements.buttons = { - 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 = getElement.call(this, this.config.selectors.progress); - - // Inputs - this.elements.inputs = { - seek: getElement.call(this, this.config.selectors.inputs.seek), - volume: getElement.call(this, this.config.selectors.inputs.volume), - }; - - // Display - this.elements.display = { - 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 (is.element(this.elements.progress)) { - this.elements.display.seekTooltip = this.elements.progress.querySelector( - `.${this.config.classNames.tooltip}`, - ); - } - - return true; - } catch (error) { - // Log it - this.debug.warn('It looks like there is a problem with your custom controls HTML', error); - - // Restore native video controls - this.toggleNativeControls(true); - - return false; - } - }, - - // Create <svg> icon - createIcon(type, attributes) { - const namespace = 'http://www.w3.org/2000/svg'; - const iconUrl = controls.getIconUrl.call(this); - const iconPath = `${!iconUrl.cors ? iconUrl.url : ''}#${this.config.iconPrefix}`; - // Create <svg> - const icon = document.createElementNS(namespace, 'svg'); - setAttributes( - icon, - extend(attributes, { - 'aria-hidden': 'true', - focusable: 'false', - }), - ); - - // Create the <use> to reference sprite - const use = document.createElementNS(namespace, 'use'); - const path = `${iconPath}-${type}`; - - // Set `href` attributes - // https://github.com/sampotts/plyr/issues/460 - // 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); - } - - // 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); - - return icon; - }, - - // Create hidden text label - createLabel(key, attr = {}) { - const text = i18n.get(key, this.config); - const attributes = { ...attr, class: [attr.class, this.config.classNames.hidden].filter(Boolean).join(' ') }; - - return createElement('span', attributes, text); - }, - - // Create a badge - createBadge(text) { - if (is.empty(text)) { - return null; - } - - const badge = createElement('span', { - class: this.config.classNames.menu.value, + // Get icon URL + getIconUrl() { + const url = new URL(this.config.iconUrl, window.location); + const cors = url.host !== window.location.host || (browser.isIE && !window.svg4everybody); + + return { + url: this.config.iconUrl, + cors, + }; + }, + + // Find the UI controls + findElements() { + try { + this.elements.controls = getElement.call(this, this.config.selectors.controls.wrapper); + + // Buttons + this.elements.buttons = { + 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 = getElement.call(this, this.config.selectors.progress); + + // Inputs + this.elements.inputs = { + seek: getElement.call(this, this.config.selectors.inputs.seek), + volume: getElement.call(this, this.config.selectors.inputs.volume), + }; + + // Display + this.elements.display = { + 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 (is.element(this.elements.progress)) { + this.elements.display.seekTooltip = this.elements.progress.querySelector(`.${this.config.classNames.tooltip}`); + } + + return true; + } catch (error) { + // Log it + this.debug.warn('It looks like there is a problem with your custom controls HTML', error); + + // Restore native video controls + this.toggleNativeControls(true); + + return false; + } + }, + + // Create <svg> icon + createIcon(type, attributes) { + const namespace = 'http://www.w3.org/2000/svg'; + const iconUrl = controls.getIconUrl.call(this); + const iconPath = `${!iconUrl.cors ? iconUrl.url : ''}#${this.config.iconPrefix}`; + // Create <svg> + const icon = document.createElementNS(namespace, 'svg'); + setAttributes( + icon, + extend(attributes, { + 'aria-hidden': 'true', + focusable: 'false', + }), + ); + + // Create the <use> to reference sprite + const use = document.createElementNS(namespace, 'use'); + const path = `${iconPath}-${type}`; + + // Set `href` attributes + // https://github.com/sampotts/plyr/issues/460 + // 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); + } + + // 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); + + return icon; + }, + + // Create hidden text label + createLabel(key, attr = {}) { + const text = i18n.get(key, this.config); + const attributes = { ...attr, class: [attr.class, this.config.classNames.hidden].filter(Boolean).join(' ') }; + + return createElement('span', attributes, text); + }, + + // Create a badge + createBadge(text) { + if (is.empty(text)) { + return null; + } + + const badge = createElement('span', { + class: this.config.classNames.menu.value, + }); + + badge.appendChild( + createElement( + 'span', + { + class: this.config.classNames.menu.badge, + }, + text, + ), + ); + + return badge; + }, + + // Create a <button> + createButton(buttonType, attr) { + const attributes = extend({}, attr); + let type = toCamelCase(buttonType); + + const props = { + element: 'button', + toggle: false, + label: null, + icon: null, + labelPressed: null, + iconPressed: null, + }; + + ['element', 'icon', 'label'].forEach(key => { + if (Object.keys(attributes).includes(key)) { + props[key] = attributes[key]; + delete attributes[key]; + } + }); + + // Default to 'button' type to prevent form submission + if (props.element === 'button' && !Object.keys(attributes).includes('type')) { + attributes.type = 'button'; + } + + // Set class name + if (Object.keys(attributes).includes('class')) { + if (!attributes.class.split(' ').some(c => c === this.config.classNames.control)) { + extend(attributes, { + class: `${attributes.class} ${this.config.classNames.control}`, }); - - badge.appendChild( - createElement( - 'span', - { - class: this.config.classNames.menu.badge, - }, - text, - ), - ); - - return badge; - }, - - // Create a <button> - createButton(buttonType, attr) { - const attributes = extend({}, attr); - let type = toCamelCase(buttonType); - - const props = { - element: 'button', - toggle: false, - label: null, - icon: null, - labelPressed: null, - iconPressed: null, - }; - - ['element', 'icon', 'label'].forEach(key => { - if (Object.keys(attributes).includes(key)) { - props[key] = attributes[key]; - delete attributes[key]; - } - }); - - // Default to 'button' type to prevent form submission - if (props.element === 'button' && !Object.keys(attributes).includes('type')) { - attributes.type = 'button'; - } - - // Set class name - if (Object.keys(attributes).includes('class')) { - if (!attributes.class.split(' ').some(c => c === this.config.classNames.control)) { - extend(attributes, { - class: `${attributes.class} ${this.config.classNames.control}`, - }); - } + } + } else { + attributes.class = this.config.classNames.control; + } + + // Large play button + switch (buttonType) { + case 'play': + props.toggle = true; + props.label = 'play'; + props.labelPressed = 'pause'; + props.icon = 'play'; + props.iconPressed = 'pause'; + break; + + case 'mute': + props.toggle = true; + props.label = 'mute'; + props.labelPressed = 'unmute'; + props.icon = 'volume'; + props.iconPressed = 'muted'; + break; + + case 'captions': + props.toggle = true; + props.label = 'enableCaptions'; + props.labelPressed = 'disableCaptions'; + props.icon = 'captions-off'; + props.iconPressed = 'captions-on'; + break; + + case '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'; + props.label = 'play'; + props.icon = 'play'; + break; + + default: + 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 (props.toggle) { + // Icon + 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, props.labelPressed, { + class: 'label--pressed', + }), + ); + button.appendChild( + controls.createLabel.call(this, props.label, { + class: 'label--not-pressed', + }), + ); + } else { + button.appendChild(controls.createIcon.call(this, props.icon)); + button.appendChild(controls.createLabel.call(this, props.label)); + } + + // 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 (!is.array(this.elements.buttons[type])) { + this.elements.buttons[type] = []; + } + + this.elements.buttons[type].push(button); + } else { + this.elements.buttons[type] = button; + } + + return button; + }, + + // Create an <input type='range'> + createRange(type, attributes) { + // Seek input + const input = createElement( + 'input', + extend( + getAttributesFromSelector(this.config.selectors.inputs[type]), + { + type: 'range', + min: 0, + max: 100, + step: 0.01, + value: 0, + autocomplete: 'off', + // A11y fixes for https://github.com/sampotts/plyr/issues/905 + role: 'slider', + 'aria-label': i18n.get(type, this.config), + 'aria-valuemin': 0, + 'aria-valuemax': 100, + 'aria-valuenow': 0, + }, + attributes, + ), + ); + + this.elements.inputs[type] = input; + + // Set the fill for webkit now + controls.updateRangeFill.call(this, input); + + // Improve support on touch devices + RangeTouch.setup(input); + + return input; + }, + + // Create a <progress> + createProgress(type, attributes) { + const progress = createElement( + 'progress', + extend( + getAttributesFromSelector(this.config.selectors.display[type]), + { + min: 0, + max: 100, + value: 0, + role: 'progressbar', + 'aria-hidden': true, + }, + attributes, + ), + ); + + // Create the label inside + if (type !== 'volume') { + progress.appendChild(createElement('span', null, '0')); + + const suffixKey = { + played: 'played', + buffer: 'buffered', + }[type]; + const suffix = suffixKey ? i18n.get(suffixKey, this.config) : ''; + + progress.innerText = `% ${suffix.toLowerCase()}`; + } + + this.elements.display[type] = progress; + + return progress; + }, + + // Create time display + createTime(type, attrs) { + const attributes = getAttributesFromSelector(this.config.selectors.display[type], attrs); + + const container = createElement( + 'div', + extend(attributes, { + class: `${attributes.class ? attributes.class : ''} ${this.config.classNames.display.time} `.trim(), + 'aria-label': i18n.get(type, this.config), + }), + '00:00', + ); + + // Reference for updates + this.elements.display[type] = container; + + return container; + }, + + // Bind keyboard shortcuts for a menu item + // We have to bind to keyup otherwise Firefox triggers a click when a keydown event handler shifts focus + // https://bugzilla.mozilla.org/show_bug.cgi?id=1220143 + bindMenuItemShortcuts(menuItem, type) { + // Navigate through menus via arrow keys and space + on.call( + this, + menuItem, + 'keydown keyup', + event => { + // We only care about space and ⬆️ ⬇️️ ➡️ + if (![32, 38, 39, 40].includes(event.which)) { + return; + } + + // Prevent play / seek + event.preventDefault(); + event.stopPropagation(); + + // We're just here to prevent the keydown bubbling + if (event.type === 'keydown') { + return; + } + + const isRadioButton = matches(menuItem, '[role="menuitemradio"]'); + + // Show the respective menu + if (!isRadioButton && [32, 39].includes(event.which)) { + controls.showMenuPanel.call(this, type, true); } else { - attributes.class = this.config.classNames.control; - } + let target; - // Large play button - switch (buttonType) { - case 'play': - props.toggle = true; - props.label = 'play'; - props.labelPressed = 'pause'; - props.icon = 'play'; - props.iconPressed = 'pause'; - break; - - case 'mute': - props.toggle = true; - props.label = 'mute'; - props.labelPressed = 'unmute'; - props.icon = 'volume'; - props.iconPressed = 'muted'; - break; - - case 'captions': - props.toggle = true; - props.label = 'enableCaptions'; - props.labelPressed = 'disableCaptions'; - props.icon = 'captions-off'; - props.iconPressed = 'captions-on'; - break; - - case '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'; - props.label = 'play'; - props.icon = 'play'; - break; - - default: - if (is.empty(props.label)) { - props.label = type; - } - if (is.empty(props.icon)) { - props.icon = buttonType; - } - } + if (event.which !== 32) { + if (event.which === 40 || (isRadioButton && event.which === 39)) { + target = menuItem.nextElementSibling; - const button = createElement(props.element); + if (!is.element(target)) { + target = menuItem.parentNode.firstElementChild; + } + } else { + target = menuItem.previousElementSibling; - // Setup toggle icon and labels - if (props.toggle) { - // Icon - 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, props.labelPressed, { - class: 'label--pressed', - }), - ); - button.appendChild( - controls.createLabel.call(this, props.label, { - class: 'label--not-pressed', - }), - ); - } else { - button.appendChild(controls.createIcon.call(this, props.icon)); - button.appendChild(controls.createLabel.call(this, props.label)); - } - - // 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 (!is.array(this.elements.buttons[type])) { - this.elements.buttons[type] = []; + if (!is.element(target)) { + target = menuItem.parentNode.lastElementChild; + } } - this.elements.buttons[type].push(button); - } else { - this.elements.buttons[type] = button; + 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.call(this, menuItem, 'keyup', event => { + if (event.which !== 13) { + return; + } - return button; - }, - - // Create an <input type='range'> - createRange(type, attributes) { - // Seek input - const input = createElement( - 'input', - extend( - getAttributesFromSelector(this.config.selectors.inputs[type]), - { - type: 'range', - min: 0, - max: 100, - step: 0.01, - value: 0, - autocomplete: 'off', - // A11y fixes for https://github.com/sampotts/plyr/issues/905 - role: 'slider', - 'aria-label': i18n.get(type, this.config), - 'aria-valuemin': 0, - 'aria-valuemax': 100, - 'aria-valuenow': 0, - }, - attributes, - ), - ); - - this.elements.inputs[type] = input; - - // Set the fill for webkit now - controls.updateRangeFill.call(this, input); - - // Improve support on touch devices - RangeTouch.setup(input); - - return input; - }, - - // Create a <progress> - createProgress(type, attributes) { - const progress = createElement( - 'progress', - extend( - getAttributesFromSelector(this.config.selectors.display[type]), - { - min: 0, - max: 100, - value: 0, - role: 'progressbar', - 'aria-hidden': true, - }, - attributes, - ), - ); - - // Create the label inside - if (type !== 'volume') { - progress.appendChild(createElement('span', null, '0')); - - const suffixKey = { - played: 'played', - buffer: 'buffered', - }[type]; - const suffix = suffixKey ? i18n.get(suffixKey, this.config) : ''; - - progress.innerText = `% ${suffix.toLowerCase()}`; - } - - this.elements.display[type] = progress; - - return progress; - }, - - // Create time display - createTime(type, attrs) { - const attributes = getAttributesFromSelector(this.config.selectors.display[type], attrs); - - const container = createElement( - 'div', - extend(attributes, { - class: `${attributes.class ? attributes.class : ''} ${this.config.classNames.display.time} `.trim(), - 'aria-label': i18n.get(type, this.config), - }), - '00:00', - ); - - // Reference for updates - this.elements.display[type] = container; - - return container; - }, - - // Bind keyboard shortcuts for a menu item - // We have to bind to keyup otherwise Firefox triggers a click when a keydown event handler shifts focus - // https://bugzilla.mozilla.org/show_bug.cgi?id=1220143 - bindMenuItemShortcuts(menuItem, type) { - // Navigate through menus via arrow keys and space - on.call( - this, - menuItem, - 'keydown keyup', - event => { - // We only care about space and ⬆️ ⬇️️ ➡️ - if (![32, 38, 39, 40].includes(event.which)) { - return; - } - - // Prevent play / seek - event.preventDefault(); - event.stopPropagation(); - - // We're just here to prevent the keydown bubbling - if (event.type === 'keydown') { - return; - } - - const isRadioButton = matches(menuItem, '[role="menuitemradio"]'); - - // Show the respective menu - if (!isRadioButton && [32, 39].includes(event.which)) { - controls.showMenuPanel.call(this, type, true); - } else { - let target; - - if (event.which !== 32) { - if (event.which === 40 || (isRadioButton && event.which === 39)) { - target = menuItem.nextElementSibling; - - if (!is.element(target)) { - target = menuItem.parentNode.firstElementChild; - } - } else { - target = menuItem.previousElementSibling; - - if (!is.element(target)) { - target = menuItem.parentNode.lastElementChild; - } - } - - setFocus.call(this, target, true); - } - } - }, - false, - ); - - // 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.call(this, menuItem, 'keyup', event => { - if (event.which !== 13) { - return; - } + controls.focusFirstMenuItem.call(this, null, true); + }); + }, - controls.focusFirstMenuItem.call(this, null, true); + // 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, + }), + ); + + const flex = createElement('span'); + + // We have to set as HTML incase of special characters + flex.innerHTML = title; + + if (is.element(badge)) { + flex.appendChild(badge); + } + + menuItem.appendChild(flex); + + // Replicate radio button behaviour + Object.defineProperty(menuItem, 'checked', { + enumerable: true, + get() { + return menuItem.getAttribute('aria-checked') === 'true'; + }, + set(check) { + // Ensure exclusivity + if (check) { + Array.from(menuItem.parentNode.children) + .filter(node => matches(node, '[role="menuitemradio"]')) + .forEach(node => node.setAttribute('aria-checked', 'false')); + } + + menuItem.setAttribute('aria-checked', check ? '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 val = 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 = val; + + // Update text label inside + const label = progress.getElementsByTagName('span')[0]; + if (is.element(label)) { + label.childNodes[0].nodeValue = val; + } + } + }; + + 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 + updateSeekTooltip(event) { + // Bail if setting not true + if ( + !this.config.tooltips.seek || + !is.element(this.elements.inputs.seek) || + !is.element(this.elements.display.seekTooltip) || + this.duration === 0 + ) { + return; + } + + const visible = `${this.config.classNames.tooltip}--visible`; + const toggle = show => toggleClass(this.elements.display.seekTooltip, visible, show); + + // Hide on touch + if (this.touch) { + toggle(false); + return; + } + + // Determine percentage, if already visible + let percent = 0; + const clientRect = this.elements.progress.getBoundingClientRect(); + + 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; + } + + // Set bounds + if (percent < 0) { + percent = 0; + } else if (percent > 100) { + percent = 100; + } + + // Display the time a click would seek to + 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 (is.event(event) && ['mouseenter', 'mouseleave'].includes(event.type)) { + toggle(event.type === 'mouseenter'); + } + }, + + // 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); + }, + + // 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; + } + + // 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; + } + + // Update ARIA values + if (is.element(this.elements.inputs.seek)) { + this.elements.inputs.seek.setAttribute('aria-valuemax', this.duration); + } + + // If there's a spot to display duration + const hasDuration = is.element(this.elements.display.duration); + + // 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 there's a duration element, update content + if (hasDuration) { + controls.updateTimeDisplay.call(this, this.elements.display.duration, this.duration); + } + + // Update the tooltip (if visible) + controls.updateSeekTooltip.call(this); + }, + + // Hide/show a tab + 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.panels.quality)) { + return; + } + + const type = 'quality'; + 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)) { + this.options.quality = dedupe(options).filter(quality => this.config.quality.options.includes(quality)); + } + + // Toggle the pane and tab + const toggle = !is.empty(this.options.quality) && this.options.quality.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); + + // If we're hiding, nothing more to do + if (!toggle) { + return; + } + + // Get the badge HTML for HD, 4K etc + const getBadge = quality => { + const label = i18n.get(`qualityBadge.${quality}`, this.config); + + if (!label.length) { + return null; + } + + return controls.createBadge.call(this, label); + }; + + // 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), }); - }, - - // 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, - }), - ); - - const flex = createElement('span'); - - // We have to set as HTML incase of special characters - flex.innerHTML = title; - - if (is.element(badge)) { - flex.appendChild(badge); - } - - menuItem.appendChild(flex); - - // Replicate radio button behaviour - Object.defineProperty(menuItem, 'checked', { - enumerable: true, - get() { - return menuItem.getAttribute('aria-checked') === 'true'; - }, - set(check) { - // Ensure exclusivity - if (check) { - Array.from(menuItem.parentNode.children) - .filter(node => matches(node, '[role="menuitemradio"]')) - .forEach(node => node.setAttribute('aria-checked', 'false')); - } - - menuItem.setAttribute('aria-checked', check ? '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 val = 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 = val; - - // Update text label inside - const label = progress.getElementsByTagName('span')[0]; - if (is.element(label)) { - label.childNodes[0].nodeValue = val; - } - } - }; - - 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 - updateSeekTooltip(event) { - // Bail if setting not true - if ( - !this.config.tooltips.seek || - !is.element(this.elements.inputs.seek) || - !is.element(this.elements.display.seekTooltip) || - this.duration === 0 - ) { - return; - } - - const visible = `${this.config.classNames.tooltip}--visible`; - const toggle = show => toggleClass(this.elements.display.seekTooltip, visible, show); - - // Hide on touch - if (this.touch) { - toggle(false); - return; - } - - // Determine percentage, if already visible - let percent = 0; - const clientRect = this.elements.progress.getBoundingClientRect(); - - 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; - } - - // Set bounds - if (percent < 0) { - percent = 0; - } else if (percent > 100) { - percent = 100; - } - - // Display the time a click would seek to - 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 (is.event(event) && ['mouseenter', 'mouseleave'].includes(event.type)) { - toggle(event.type === 'mouseenter'); - } - }, - - // 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); - }, - - // 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; - } - - // 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; - } - - // Update ARIA values - if (is.element(this.elements.inputs.seek)) { - this.elements.inputs.seek.setAttribute('aria-valuemax', this.duration); - } - - // If there's a spot to display duration - const hasDuration = is.element(this.elements.display.duration); - - // 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 there's a duration element, update content - if (hasDuration) { - controls.updateTimeDisplay.call(this, this.elements.display.duration, this.duration); - } - - // Update the tooltip (if visible) - controls.updateSeekTooltip.call(this); - }, - - // Hide/show a tab - 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; - } - }, + 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; - } - }, - - // Set the quality menu - setQualityMenu(options) { - // Menu required - if (!is.element(this.elements.settings.panels.quality)) { - return; - } - - const type = 'quality'; - 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)) { - this.options.quality = dedupe(options).filter(quality => this.config.quality.options.includes(quality)); - } - - // Toggle the pane and tab - const toggle = !is.empty(this.options.quality) && this.options.quality.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); - - // If we're hiding, nothing more to do - if (!toggle) { - return; - } - - // Get the badge HTML for HD, 4K etc - const getBadge = quality => { - const label = i18n.get(`qualityBadge.${quality}`, this.config); - - if (!label.length) { - return null; - } - - return controls.createBadge.call(this, label); - }; - - // 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), - }); - }); - - controls.updateSetting.call(this, type, list); - }, - - // Set the looping options - /* setLoopMenu() { + // Set the looping options + /* setLoopMenu() { // Menu required if (!is.element(this.elements.settings.panels.loop)) { return; @@ -988,768 +986,766 @@ const controls = { }); }, */ - // Get current selected caption language - // TODO: rework this to user the getter in the API? - - // 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.panels.captions.querySelector('[role="menu"]'); - const tracks = captions.getTracks.call(this); - const toggle = Boolean(tracks.length); - - // Toggle the pane and tab - controls.toggleMenuButton.call(this, type, toggle); - - // Empty the menu - emptyElement(list); - - // Check if we need to toggle the parent - controls.checkMenu.call(this); - - // If there's no captions, bail - if (!toggle) { - return; - } - - // 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 - options.unshift({ - value: -1, - checked: !this.captions.toggled, - title: i18n.get('disabled', this.config), - list, - type: 'language', - }); - - // Generate options - options.forEach(controls.createMenuItem.bind(this)); - - controls.updateSetting.call(this, type, list); - }, - - // Set a list of available captions languages - setSpeedMenu() { - // Menu required - if (!is.element(this.elements.settings.panels.speed)) { - return; - } - - const type = 'speed'; - const list = this.elements.settings.panels.speed.querySelector('[role="menu"]'); - - // Filter out invalid speeds - this.options.speed = this.options.speed.filter(o => o >= this.minimumSpeed && o <= this.maximumSpeed); - - // Toggle the pane and tab - 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); - - // If we're hiding, nothing more to do - if (!toggle) { - return; - } - - // Create items - this.options.speed.forEach(speed => { - controls.createMenuItem.call(this, { - value: speed, - list, - type, - title: controls.getLabel.call(this, 'speed', speed), - }); + // Get current selected caption language + // TODO: rework this to user the getter in the API? + + // 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.panels.captions.querySelector('[role="menu"]'); + const tracks = captions.getTracks.call(this); + const toggle = Boolean(tracks.length); + + // Toggle the pane and tab + controls.toggleMenuButton.call(this, type, toggle); + + // Empty the menu + emptyElement(list); + + // Check if we need to toggle the parent + controls.checkMenu.call(this); + + // If there's no captions, bail + if (!toggle) { + return; + } + + // 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 + options.unshift({ + value: -1, + checked: !this.captions.toggled, + title: i18n.get('disabled', this.config), + list, + type: 'language', + }); + + // Generate options + options.forEach(controls.createMenuItem.bind(this)); + + controls.updateSetting.call(this, type, list); + }, + + // Set a list of available captions languages + setSpeedMenu() { + // Menu required + if (!is.element(this.elements.settings.panels.speed)) { + return; + } + + const type = 'speed'; + const list = this.elements.settings.panels.speed.querySelector('[role="menu"]'); + + // Filter out invalid speeds + this.options.speed = this.options.speed.filter(o => o >= this.minimumSpeed && o <= this.maximumSpeed); + + // Toggle the pane and tab + 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); + + // If we're hiding, nothing more to do + if (!toggle) { + return; + } + + // Create items + this.options.speed.forEach(speed => { + controls.createMenuItem.call(this, { + value: speed, + list, + type, + title: controls.getLabel.call(this, 'speed', speed), + }); + }); + + controls.updateSetting.call(this, type, list); + }, + + // Check if we need to hide/show the settings menu + checkMenu() { + 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(p => !p.hidden); + } + + const firstItem = target.querySelector('[role^="menuitem"]'); + + setFocus.call(this, firstItem, tabFocus); + }, + + // Show/hide menu + toggleMenu(input) { + const { popup } = this.elements.settings; + const button = this.elements.buttons.settings; + + // Menu and button are required + if (!is.element(popup) || !is.element(button)) { + return; + } + + // True toggle by default + const { hidden } = popup; + let show = hidden; + + if (is.boolean(input)) { + show = input; + } else if (is.keyboardEvent(input) && input.which === 27) { + show = false; + } else if (is.event(input)) { + // If Plyr is in a shadowDOM, the event target is set to the component, instead of the + // Element in the shadowDOM. The path, if available, is complete. + const target = is.function(input.composedPath) ? input.composedPath()[0] : input.target; + const isMenuItem = popup.contains(target); + + // 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 && input.target !== button && show)) { + return; + } + } + + // Set button attributes + button.setAttribute('aria-expanded', show); + + // Show the actual popup + toggleHidden(popup, !show); + + // 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 menu panel + getMenuSize(tab) { + const clone = tab.cloneNode(true); + clone.style.position = 'absolute'; + clone.style.opacity = 0; + clone.removeAttribute('hidden'); + + // Append to parent so we get the "real" size + tab.parentNode.appendChild(clone); + + // Get the sizes before we remove + const width = clone.scrollWidth; + const height = clone.scrollHeight; + + // Remove from the DOM + removeElement(clone); + + return { + width, + height, + }; + }, + + // Show a panel in the menu + showMenuPanel(type = '', tabFocus = false) { + const target = this.elements.container.querySelector(`#plyr-settings-${this.id}-${type}`); + + // Nothing to show, bail + if (!is.element(target)) { + return; + } + + // 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) { + // Set the current width as a base + container.style.width = `${current.scrollWidth}px`; + container.style.height = `${current.scrollHeight}px`; + + // Get potential sizes + const size = controls.getMenuSize.call(this, target); + + // Restore auto height/width + const restore = event => { + // We're only bothered about height and width on the container + if (event.target !== container || !['width', 'height'].includes(event.propertyName)) { + return; + } + + // Revert back to auto + container.style.width = ''; + container.style.height = ''; + + // Only listen once + off.call(this, container, transitionEndEvent, restore); + }; + + // Listen for the transition finishing and restore auto height/width + on.call(this, container, transitionEndEvent, restore); + + // Set dimensions to target + container.style.width = `${size.width}px`; + container.style.height = `${size.height}px`; + } + + // Set attributes on current tab + toggleHidden(current, true); + + // Set attributes on target + toggleHidden(target, false); + + // Focus the first item + controls.focusFirstMenuItem.call(this, target, tabFocus); + }, + + // Set the download URL + setDownloadUrl() { + const button = this.elements.buttons.download; + + // Bail if no button + if (!is.element(button)) { + return; + } + + // Set attribute + button.setAttribute('href', this.download); + }, + + // Build the default HTML + create(data) { + const { + bindMenuItemShortcuts, + createButton, + createProgress, + createRange, + createTime, + setQualityMenu, + setSpeedMenu, + showMenuPanel, + } = controls; + this.elements.controls = null; + + // Larger overlaid play button + if (is.array(this.config.controls) && this.config.controls.includes('play-large')) { + this.elements.container.appendChild(createButton.call(this, 'play-large')); + } + + // Create the container + const container = createElement('div', getAttributesFromSelector(this.config.selectors.controls.wrapper)); + this.elements.controls = container; + + // Default item attributes + const defaultAttributes = { class: 'plyr__controls__item' }; + + // Loop through controls in order + dedupe(is.array(this.config.controls) ? this.config.controls: []).forEach(control => { + // Restart button + if (control === 'restart') { + container.appendChild(createButton.call(this, 'restart', defaultAttributes)); + } + + // Rewind button + if (control === 'rewind') { + container.appendChild(createButton.call(this, 'rewind', defaultAttributes)); + } + + // Play/Pause button + if (control === 'play') { + container.appendChild(createButton.call(this, 'play', defaultAttributes)); + } + + // Fast forward button + if (control === 'fast-forward') { + container.appendChild(createButton.call(this, 'fast-forward', defaultAttributes)); + } + + // Progress + if (control === 'progress') { + const progressContainer = createElement('div', { + class: `${defaultAttributes.class} plyr__progress__container`, }); - controls.updateSetting.call(this, type, list); - }, + const progress = createElement('div', getAttributesFromSelector(this.config.selectors.progress)); - // Check if we need to hide/show the settings menu - checkMenu() { - const { buttons } = this.elements.settings; - const visible = !is.empty(buttons) && Object.values(buttons).some(button => !button.hidden); + // Seek range slider + progress.appendChild( + createRange.call(this, 'seek', { + id: `plyr-seek-${data.id}`, + }), + ); - toggleHidden(this.elements.settings.menu, !visible); - }, + // Buffer progress + progress.appendChild(createProgress.call(this, 'buffer')); - // Focus the first menu item in a given (or visible) menu - focusFirstMenuItem(pane, tabFocus = false) { - if (this.elements.settings.popup.hidden) { - return; - } + // TODO: Add loop display indicator - let target = pane; + // Seek tooltip + if (this.config.tooltips.seek) { + const tooltip = createElement( + 'span', + { + class: this.config.classNames.tooltip, + }, + '00:00', + ); - if (!is.element(target)) { - target = Object.values(this.elements.settings.panels).find(p => !p.hidden); + progress.appendChild(tooltip); + this.elements.display.seekTooltip = tooltip; } - const firstItem = target.querySelector('[role^="menuitem"]'); - - setFocus.call(this, firstItem, tabFocus); - }, + this.elements.progress = progress; + progressContainer.appendChild(this.elements.progress); + container.appendChild(progressContainer); + } - // Show/hide menu - toggleMenu(input) { - const { popup } = this.elements.settings; - const button = this.elements.buttons.settings; - - // Menu and button are required - if (!is.element(popup) || !is.element(button)) { - return; - } + // Media current time display + if (control === 'current-time') { + container.appendChild(createTime.call(this, 'currentTime', defaultAttributes)); + } - // True toggle by default - const { hidden } = popup; - let show = hidden; - - if (is.boolean(input)) { - show = input; - } else if (is.keyboardEvent(input) && input.which === 27) { - show = false; - } else if (is.event(input)) { - // If Plyr is in a shadowDOM, the event target is set to the component, instead of the - // Element in the shadowDOM. The path, if available, is complete. - const target = is.function(input.composedPath) ? input.composedPath()[0] : input.target; - const isMenuItem = popup.contains(target); - - // 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 && input.target !== button && show)) { - return; - } - } + // Media duration display + if (control === 'duration') { + container.appendChild(createTime.call(this, 'duration', defaultAttributes)); + } - // Set button attributes - button.setAttribute('aria-expanded', show); + // Volume controls + if (control === 'mute' || control === 'volume') { + let { volume } = this.elements; - // Show the actual popup - toggleHidden(popup, !show); + // Create the volume container if needed + if (!is.element(volume) || !container.contains(volume)) { + volume = createElement( + 'div', + extend({}, defaultAttributes, { + class: `${defaultAttributes.class} plyr__volume`.trim(), + }), + ); - // Add class hook - toggleClass(this.elements.container, this.config.classNames.menu.open, show); + this.elements.volume = volume; - // 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)); + container.appendChild(volume); } - }, - - // 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'); - - // Append to parent so we get the "real" size - tab.parentNode.appendChild(clone); - - // Get the sizes before we remove - const width = clone.scrollWidth; - const height = clone.scrollHeight; - - // Remove from the DOM - removeElement(clone); - - return { - width, - height, - }; - }, - - // Show a panel in the menu - showMenuPanel(type = '', tabFocus = false) { - const target = this.elements.container.querySelector(`#plyr-settings-${this.id}-${type}`); - // Nothing to show, bail - if (!is.element(target)) { - return; + // Toggle mute button + if (control === 'mute') { + volume.appendChild(createButton.call(this, 'mute')); } - // Hide all other panels - const container = target.parentNode; - const current = Array.from(container.children).find(node => !node.hidden); + // Volume range control + // Ignored on iOS as it's handled globally + // https://developer.apple.com/library/safari/documentation/AudioVideo/Conceptual/Using_HTML5_Audio_Video/Device-SpecificConsiderations/Device-SpecificConsiderations.html + if (control === 'volume' && !browser.isIos) { + // Set the attributes + const attributes = { + max: 1, + step: 0.05, + value: this.config.volume, + }; - // If we can do fancy animations, we'll animate the height/width - if (support.transitions && !support.reducedMotion) { - // Set the current width as a base - container.style.width = `${current.scrollWidth}px`; - container.style.height = `${current.scrollHeight}px`; + // Create the volume range slider + volume.appendChild( + createRange.call( + this, + 'volume', + extend(attributes, { + id: `plyr-volume-${data.id}`, + }), + ), + ); + } + } + + // Toggle captions button + if (control === 'captions') { + container.appendChild(createButton.call(this, 'captions', defaultAttributes)); + } + + // Settings button / menu + if (control === 'settings' && !is.empty(this.config.settings)) { + const wrapper = createElement( + 'div', + extend({}, defaultAttributes, { + class: `${defaultAttributes.class} plyr__menu`.trim(), + hidden: '', + }), + ); - // Get potential sizes - const size = controls.getMenuSize.call(this, target); + wrapper.appendChild( + createButton.call(this, 'settings', { + 'aria-haspopup': true, + 'aria-controls': `plyr-settings-${data.id}`, + 'aria-expanded': false, + }), + ); - // Restore auto height/width - const restore = event => { - // We're only bothered about height and width on the container - if (event.target !== container || !['width', 'height'].includes(event.propertyName)) { - return; - } + const popup = createElement('div', { + class: 'plyr__menu__container', + id: `plyr-settings-${data.id}`, + hidden: '', + }); - // Revert back to auto - container.style.width = ''; - container.style.height = ''; + const inner = createElement('div'); - // Only listen once - off.call(this, container, transitionEndEvent, restore); - }; + const home = createElement('div', { + id: `plyr-settings-${data.id}-home`, + }); - // Listen for the transition finishing and restore auto height/width - on.call(this, container, transitionEndEvent, restore); + // Create the menu + const menu = createElement('div', { + role: 'menu', + }); - // Set dimensions to target - container.style.width = `${size.width}px`; - container.style.height = `${size.height}px`; - } + home.appendChild(menu); + inner.appendChild(home); + this.elements.settings.panels.home = home; - // Set attributes on current tab - toggleHidden(current, true); + // 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`, + role: 'menuitem', + 'aria-haspopup': true, + hidden: '', + }), + ); - // Set attributes on target - toggleHidden(target, false); + // Bind menu shortcuts for keyboard users + bindMenuItemShortcuts.call(this, menuItem, type); - // Focus the first item - controls.focusFirstMenuItem.call(this, target, tabFocus); - }, + // Show menu on click + on.call(this, menuItem, 'click', () => { + showMenuPanel.call(this, type, false); + }); - // Set the download URL - setDownloadUrl() { - const button = this.elements.buttons.download; + const flex = createElement('span', null, i18n.get(type, this.config)); - // Bail if no button - if (!is.element(button)) { - return; - } - - // Set attribute - button.setAttribute('href', this.download); - }, - - // Build the default HTML - create(data) { - const { - bindMenuItemShortcuts, - createButton, - createProgress, - createRange, - createTime, - setQualityMenu, - setSpeedMenu, - showMenuPanel, - } = controls; - this.elements.controls = null; - - // Larger overlaid play button - if (this.config.controls.includes('play-large')) { - this.elements.container.appendChild(createButton.call(this, 'play-large')); - } - - // Create the container - const container = createElement('div', getAttributesFromSelector(this.config.selectors.controls.wrapper)); - this.elements.controls = container; + const value = createElement('span', { + class: this.config.classNames.menu.value, + }); - // Default item attributes - const defaultAttributes = { class: 'plyr__controls__item' }; + // Speed contains HTML entities + value.innerHTML = data[type]; - // Loop through controls in order - dedupe(this.config.controls).forEach(control => { - // Restart button - if (control === 'restart') { - container.appendChild(createButton.call(this, 'restart', defaultAttributes)); - } + flex.appendChild(value); + menuItem.appendChild(flex); + menu.appendChild(menuItem); - // Rewind button - if (control === 'rewind') { - container.appendChild(createButton.call(this, 'rewind', defaultAttributes)); - } + // Build the panes + const pane = createElement('div', { + id: `plyr-settings-${data.id}-${type}`, + hidden: '', + }); - // Play/Pause button - if (control === 'play') { - container.appendChild(createButton.call(this, 'play', defaultAttributes)); - } + // Back button + const backButton = createElement('button', { + type: 'button', + class: `${this.config.classNames.control} ${this.config.classNames.control}--back`, + }); - // Fast forward button - if (control === 'fast-forward') { - container.appendChild(createButton.call(this, 'fast-forward', defaultAttributes)); - } + // Visible label + backButton.appendChild( + createElement( + 'span', + { + 'aria-hidden': true, + }, + i18n.get(type, this.config), + ), + ); - // Progress - if (control === 'progress') { - const progressContainer = createElement('div', { - class: `${defaultAttributes.class} plyr__progress__container`, - }); - - const progress = createElement('div', getAttributesFromSelector(this.config.selectors.progress)); - - // Seek range slider - progress.appendChild( - createRange.call(this, 'seek', { - id: `plyr-seek-${data.id}`, - }), - ); - - // Buffer progress - progress.appendChild(createProgress.call(this, 'buffer')); - - // TODO: Add loop display indicator - - // Seek tooltip - if (this.config.tooltips.seek) { - const tooltip = createElement( - 'span', - { - class: this.config.classNames.tooltip, - }, - '00:00', - ); - - progress.appendChild(tooltip); - this.elements.display.seekTooltip = tooltip; - } - - this.elements.progress = progress; - progressContainer.appendChild(this.elements.progress); - container.appendChild(progressContainer); - } + // Screen reader label + backButton.appendChild( + createElement( + 'span', + { + class: this.config.classNames.hidden, + }, + i18n.get('menuBack', this.config), + ), + ); - // Media current time display - if (control === 'current-time') { - container.appendChild(createTime.call(this, 'currentTime', defaultAttributes)); - } + // Go back via keyboard + on.call( + this, + pane, + 'keydown', + event => { + // We only care about <- + if (event.which !== 37) { + return; + } - // Media duration display - if (control === 'duration') { - container.appendChild(createTime.call(this, 'duration', defaultAttributes)); - } + // Prevent seek + event.preventDefault(); + event.stopPropagation(); - // Volume controls - if (control === 'mute' || control === 'volume') { - let { volume } = this.elements; - - // Create the volume container if needed - if (!is.element(volume) || !container.contains(volume)) { - volume = createElement( - 'div', - extend({}, defaultAttributes, { - class: `${defaultAttributes.class} plyr__volume`.trim(), - }), - ); - - this.elements.volume = volume; - - container.appendChild(volume); - } - - // Toggle mute button - if (control === 'mute') { - volume.appendChild(createButton.call(this, 'mute')); - } - - // Volume range control - // Ignored on iOS as it's handled globally - // https://developer.apple.com/library/safari/documentation/AudioVideo/Conceptual/Using_HTML5_Audio_Video/Device-SpecificConsiderations/Device-SpecificConsiderations.html - if (control === 'volume' && !browser.isIos) { - // Set the attributes - const attributes = { - max: 1, - step: 0.05, - value: this.config.volume, - }; - - // Create the volume range slider - volume.appendChild( - createRange.call( - this, - 'volume', - extend(attributes, { - id: `plyr-volume-${data.id}`, - }), - ), - ); - } - } + // Show the respective menu + showMenuPanel.call(this, 'home', true); + }, + false, + ); - // Toggle captions button - if (control === 'captions') { - container.appendChild(createButton.call(this, 'captions', defaultAttributes)); - } + // Go back via button click + on.call(this, backButton, 'click', () => { + showMenuPanel.call(this, 'home', false); + }); - // Settings button / menu - if (control === 'settings' && !is.empty(this.config.settings)) { - const wrapper = createElement( - 'div', - extend({}, defaultAttributes, { - class: `${defaultAttributes.class} plyr__menu`.trim(), - hidden: '', - }), - ); - - wrapper.appendChild( - createButton.call(this, 'settings', { - 'aria-haspopup': true, - 'aria-controls': `plyr-settings-${data.id}`, - 'aria-expanded': false, - }), - ); - - const popup = createElement('div', { - class: 'plyr__menu__container', - id: `plyr-settings-${data.id}`, - hidden: '', - }); - - const inner = createElement('div'); - - const home = createElement('div', { - id: `plyr-settings-${data.id}-home`, - }); - - // Create the menu - const menu = createElement('div', { - role: 'menu', - }); - - home.appendChild(menu); - inner.appendChild(home); - this.elements.settings.panels.home = home; - - // 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`, - role: 'menuitem', - 'aria-haspopup': true, - hidden: '', - }), - ); - - // Bind menu shortcuts for keyboard users - bindMenuItemShortcuts.call(this, menuItem, type); - - // Show menu on click - on.call(this, menuItem, 'click', () => { - 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]; - - flex.appendChild(value); - menuItem.appendChild(flex); - menu.appendChild(menuItem); - - // Build the panes - const pane = createElement('div', { - id: `plyr-settings-${data.id}-${type}`, - hidden: '', - }); - - // 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.call( - this, - pane, - 'keydown', - event => { - // We only care about <- - if (event.which !== 37) { - return; - } - - // Prevent seek - event.preventDefault(); - event.stopPropagation(); - - // Show the respective menu - showMenuPanel.call(this, 'home', true); - }, - false, - ); - - // Go back via button click - on.call(this, backButton, 'click', () => { - showMenuPanel.call(this, 'home', false); - }); - - // Add to pane - pane.appendChild(backButton); - - // Menu - pane.appendChild( - createElement('div', { - role: 'menu', - }), - ); - - inner.appendChild(pane); - - this.elements.settings.buttons[type] = menuItem; - this.elements.settings.panels[type] = pane; - }); - - popup.appendChild(inner); - wrapper.appendChild(popup); - container.appendChild(wrapper); - - this.elements.settings.popup = popup; - this.elements.settings.menu = wrapper; - } + // Add to pane + pane.appendChild(backButton); - // Picture in picture button - if (control === 'pip' && support.pip) { - container.appendChild(createButton.call(this, 'pip', defaultAttributes)); - } + // Menu + pane.appendChild( + createElement('div', { + role: 'menu', + }), + ); - // Airplay button - if (control === 'airplay' && support.airplay) { - container.appendChild(createButton.call(this, 'airplay', defaultAttributes)); - } + inner.appendChild(pane); - // Download button - if (control === 'download') { - const attributes = extend({}, defaultAttributes, { - element: 'a', - href: this.download, - target: '_blank', - }); - - // Set download attribute for HTML5 only - if (this.isHTML5) { - attributes.download = ''; - } - - const { download } = this.config.urls; - - if (!is.url(download) && this.isEmbed) { - extend(attributes, { - icon: `logo-${this.provider}`, - label: this.provider, - }); - } - - container.appendChild(createButton.call(this, 'download', attributes)); - } + this.elements.settings.buttons[type] = menuItem; + this.elements.settings.panels[type] = pane; + }); - // Toggle fullscreen button - if (control === 'fullscreen') { - container.appendChild(createButton.call(this, 'fullscreen', defaultAttributes)); - } + popup.appendChild(inner); + wrapper.appendChild(popup); + container.appendChild(wrapper); + + this.elements.settings.popup = popup; + this.elements.settings.menu = wrapper; + } + + // Picture in picture button + if (control === 'pip' && support.pip) { + container.appendChild(createButton.call(this, 'pip', defaultAttributes)); + } + + // Airplay button + if (control === 'airplay' && support.airplay) { + container.appendChild(createButton.call(this, 'airplay', defaultAttributes)); + } + + // Download button + if (control === 'download') { + const attributes = extend({}, defaultAttributes, { + element: 'a', + href: this.download, + target: '_blank', }); - // Set available quality levels + // Set download attribute for HTML5 only if (this.isHTML5) { - setQualityMenu.call(this, html5.getQualityOptions.call(this)); - } - - setSpeedMenu.call(this); - - return container; - }, - - // Insert controls - inject() { - // Sprite - if (this.config.loadSprite) { - const icon = controls.getIconUrl.call(this); - - // Only load external sprite using AJAX - if (icon.cors) { - loadSprite(icon.url, 'sprite-plyr'); - } - } - - // Create a unique ID - this.id = Math.floor(Math.random() * 10000); - - // Null by default - let container = null; - this.elements.controls = null; - - // Set template properties - const props = { - id: this.id, - seektime: this.config.seekTime, - title: this.config.title, - }; - let update = true; - - // 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 { - // Create controls - container = controls.create.call(this, { - id: this.id, - seektime: this.config.seekTime, - speed: this.speed, - quality: this.quality, - captions: captions.getLabel.call(this), - // TODO: Looping - // loop: 'None', - }); - update = false; - } - - // Replace props with their value - const replace = input => { - let result = input; - - Object.entries(props).forEach(([key, value]) => { - result = replaceAll(result, `{${key}}`, value); - }); - - return result; - }; - - // Update markup - if (update) { - if (is.string(this.config.controls)) { - container = replace(container); - } else if (is.element(container)) { - container.innerHTML = replace(container.innerHTML); - } - } - - // Controls container - let target; - - // Inject to custom location - if (is.string(this.config.selectors.controls.container)) { - target = document.querySelector(this.config.selectors.controls.container); - } - - // Inject into the container by default - if (!is.element(target)) { - target = this.elements.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 (!is.element(this.elements.controls)) { - controls.findElements.call(this); - } - - // 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 { classNames, selectors } = this.config; - const selector = `${selectors.controls.wrapper} ${selectors.labels} .${classNames.hidden}`; - const labels = getElements.call(this, selector); - - Array.from(labels).forEach(label => { - toggleClass(label, this.config.classNames.hidden, false); - toggleClass(label, this.config.classNames.tooltip, true); - }); - } - }, + attributes.download = ''; + } + + const { download } = this.config.urls; + + if (!is.url(download) && this.isEmbed) { + extend(attributes, { + icon: `logo-${this.provider}`, + label: this.provider, + }); + } + + container.appendChild(createButton.call(this, 'download', attributes)); + } + + // Toggle fullscreen button + if (control === 'fullscreen') { + container.appendChild(createButton.call(this, 'fullscreen', defaultAttributes)); + } + }); + + // Set available quality levels + if (this.isHTML5) { + setQualityMenu.call(this, html5.getQualityOptions.call(this)); + } + + setSpeedMenu.call(this); + + return container; + }, + + // Insert controls + inject() { + // Sprite + if (this.config.loadSprite) { + const icon = controls.getIconUrl.call(this); + + // Only load external sprite using AJAX + if (icon.cors) { + loadSprite(icon.url, 'sprite-plyr'); + } + } + + // Create a unique ID + this.id = Math.floor(Math.random() * 10000); + + // Null by default + let container = null; + this.elements.controls = null; + + // Set template properties + const props = { + id: this.id, + seektime: this.config.seekTime, + title: this.config.title, + }; + let update = true; + + // 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 { + // Create controls + container = controls.create.call(this, { + id: this.id, + seektime: this.config.seekTime, + speed: this.speed, + quality: this.quality, + captions: captions.getLabel.call(this), + // TODO: Looping + // loop: 'None', + }); + update = false; + } + + // Replace props with their value + const replace = input => { + let result = input; + + Object.entries(props).forEach(([key, value]) => { + result = replaceAll(result, `{${key}}`, value); + }); + + return result; + }; + + // Update markup + if (update) { + if (is.string(this.config.controls)) { + container = replace(container); + } + } + + // Controls container + let target; + + // Inject to custom location + if (is.string(this.config.selectors.controls.container)) { + target = document.querySelector(this.config.selectors.controls.container); + } + + // Inject into the container by default + if (!is.element(target)) { + target = this.elements.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 (!is.element(this.elements.controls)) { + controls.findElements.call(this); + } + + // 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 { classNames, selectors } = this.config; + const selector = `${selectors.controls.wrapper} ${selectors.labels} .${classNames.hidden}`; + const labels = getElements.call(this, selector); + + Array.from(labels).forEach(label => { + toggleClass(label, this.config.classNames.hidden, false); + toggleClass(label, this.config.classNames.tooltip, true); + }); + } + }, }; export default controls; |