UNPKG

plyr

Version:

A simple, accessible and customizable HTML5, YouTube and Vimeo media player

1,485 lines (1,212 loc) 59.1 kB
// ========================================================================== // Plyr controls // TODO: This needs to be split into smaller files and cleaned up // ========================================================================== import RangeTouch from 'rangetouch'; import captions from './captions'; import html5 from './html5'; import support from './support'; import { repaint, transitionEndEvent } from './utils/animation'; import { dedupe } from './utils/arrays'; import browser from './utils/browser'; import { createElement, emptyElement, getAttributesFromSelector, getElement, getElements, hasClass, matches, removeElement, setAttributes, 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/load-sprite'; 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 = { // 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, { role: 'presentation', 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 = Object.assign({}, 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}`, }); } } 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( 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(menuItem, 'keyup', event => { if (event.which !== 13) { return; } 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}&times;`; 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() { // Menu required if (!is.element(this.elements.settings.panels.loop)) { return; } const options = ['start', 'end', 'all', 'reset']; const list = this.elements.settings.panels.loop.querySelector('[role="menu"]'); // Show the pane and tab toggleHidden(this.elements.settings.buttons.loop, false); toggleHidden(this.elements.settings.panels.loop, false); // Toggle the pane and tab const toggle = !is.empty(this.loop.options); controls.toggleMenuButton.call(this, 'loop', toggle); // Empty the menu emptyElement(list); options.forEach(option => { const item = createElement('li'); const button = createElement( 'button', extend(getAttributesFromSelector(this.config.selectors.buttons.loop), { type: 'button', class: this.config.classNames.control, 'data-plyr-loop-action': option, }), i18n.get(option, this.config) ); if (['start', 'end'].includes(option)) { const badge = controls.createBadge.call(this, '00:00'); button.appendChild(badge); } item.appendChild(button); list.appendChild(item); }); }, */ // 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(options) { // Menu required if (!is.element(this.elements.settings.panels.speed)) { return; } const type = 'speed'; const list = this.elements.settings.panels.speed.querySelector('[role="menu"]'); // Set the speed options if (is.array(options)) { 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]; } // 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 = !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 (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(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`, }); 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); } // Media current time display if (control === 'current-time') { container.appendChild(createTime.call(this, 'currentTime', defaultAttributes)); } // Media duration display if (control === 'duration') { container.appendChild(createTime.call(this, 'duration', defaultAttributes)); } // 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 if (control === 'volume') { // 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}`, }), ), ); } } // 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: '', }), ); 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(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', { i