UNPKG

plyr

Version:

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

1,736 lines (1,418 loc) 53.7 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 host = window.location.host ? window.location.host : window.top.location.host; const cors = url.host !== 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(' ').includes(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.textContent = `% ${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), 'role': 'timer', }), '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 (![' ', 'ArrowUp', 'ArrowDown', 'ArrowRight'].includes(event.key)) { 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 && [' ', 'ArrowRight'].includes(event.key)) { controls.showMenuPanel.call(this, type, true); } else { let target; if (event.key !== ' ') { if (event.key === 'ArrowDown' || (isRadioButton && event.key === 'ArrowRight')) { 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.key !== 'Return') 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 behavior 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.key !== ' ') { 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 = Number.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; } target.textContent = 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; } 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 && !browser.isIPadOS) { 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 tipElement = this.elements.display.seekTooltip; const visible = `${this.config.classNames.tooltip}--visible`; const toggle = show => toggleClass(tipElement, 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)) { const scrollLeft = event.pageX - event.clientX; percent = (100 / clientRect.width) * (event.pageX - clientRect.left - scrollLeft); } else if (hasClass(tipElement, visible)) { percent = Number.parseFloat(tipElement.style.left, 10); } else { return; } // Set bounds if (percent < 0) { percent = 0; } else if (percent > 100) { percent = 100; } const time = (this.duration / 100) * percent; // Display the time a click would seek to tipElement.textContent = controls.formatTime(time); // Get marker point for time const point = this.config.markers?.points?.find(({ time: t }) => t === Math.round(time)); // Append the point label to the tooltip if (point) { tipElement.insertAdjacentHTML('afterbegin', `${point.label}<br>`); } // Set position tipElement.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); } if (this.config.markers.enabled) { controls.setMarkers.call(this); } // 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() { // 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, focusVisible = 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, focusVisible); }, // 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.key === 'Escape') { 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 = '', focusVisible = 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, focusVisible); }, // 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`, }); 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 // 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 && !browser.isIPadOS) { // 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.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) => { if (event.key !== 'ArrowLeft') 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; } // 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 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)); } // 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; button.setAttribute('aria-pressed', 'false'); Object.defineProperty(button, 'pressed'