// ========================================================================== // 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 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 const icon = document.createElementNS(namespace, 'svg'); setAttributes( icon, extend(attributes, { 'aria-hidden': 'true', 'focusable': 'false', }), ); // Create the 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 to 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
`); } // 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}×`; 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', { configurable: true, enumerable: true, get() { return hasClass(button, className); }, set(pressed = false) { toggleClass(button, className, pressed); button.setAttribute('aria-pressed', pressed ? 'true' : 'false'); }, }); }; // 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); }); } }, // Set media metadata setMediaMetadata() { try { if ('mediaSession' in navigator) { navigator.mediaSession.metadata = new window.MediaMetadata({ title: this.config.mediaMetadata.title, artist: this.config.mediaMetadata.artist, album: this.config.mediaMetadata.album, artwork: this.config.mediaMetadata.artwork, }); } } catch { // Do nothing } }, // Add markers setMarkers() { if (!this.duration || this.elements.markers) return; // Get valid points const points = this.config.markers?.points?.filter(({ time }) => time > 0 && time < this.duration); if (!points?.length) return; const containerFragment = document.createDocumentFragment(); const pointsFragment = document.createDocumentFragment(); let tipElement = null; const tipVisible = `${this.config.classNames.tooltip}--visible`; const toggleTip = show => toggleClass(tipElement, tipVisible, show); // Inject markers to progress container points.forEach((point) => { const markerElement = createElement( 'span', { class: this.config.classNames.marker, }, '', ); const left = `${(point.time / this.duration) * 100}%`; if (tipElement) { // Show on hover markerElement.addEventListener('mouseenter', () => { if (point.label) return; tipElement.style.left = left; tipElement.innerHTML = point.label; toggleTip(true); }); // Hide on leave markerElement.addEventListener('mouseleave', () => { toggleTip(false); }); } markerElement.addEventListener('click', () => { this.currentTime = point.time; }); markerElement.style.left = left; pointsFragment.appendChild(markerElement); }); containerFragment.appendChild(pointsFragment); // Inject a tooltip if needed if (!this.config.tooltips.seek) { tipElement = createElement( 'span', { class: this.config.classNames.tooltip, }, '', ); containerFragment.appendChild(tipElement); } this.elements.markers = { points: pointsFragment, tip: tipElement, }; this.elements.progress.appendChild(containerFragment); }, }; export default controls;