// ==========================================================================
// Plyr controls
// TODO: This needs to be split into smaller files and cleaned up
// ==========================================================================

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/loadSprite';
import { extend } from './utils/objects';
import { getPercentage, replaceAll, toCamelCase, toTitleCase } from './utils/strings';
import { formatTime, getHours } from './utils/time';

// TODO: Don't export a massive object - break down and create class
const controls = {
    // 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 = Object.assign({}, 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.includes(this.config.classNames.control)) {
                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);

        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: 'presentation',
                    '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) {
        const attributes = getAttributesFromSelector(this.config.selectors.display[type]);

        const container = createElement(
            'div',
            extend(attributes, {
                class: `${this.config.classNames.display.time} ${attributes.class ? attributes.class : ''}`.trim(),
                'aria-label': i18n.get(type, this.config),
            }),
            '00:00',
        );

        // Reference for updates
        this.elements.display[type] = container;

        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(checked) {
                // Ensure exclusivity
                if (checked) {
                    Array.from(menuItem.parentNode.children)
                        .filter(node => matches(node, '[role="menuitemradio"]'))
                        .forEach(node => node.setAttribute('aria-checked', 'false'));
                }

                menuItem.setAttribute('aria-checked', checked ? 'true' : 'false');
            },
        });

        this.listeners.bind(
            menuItem,
            'click keyup',
            event => {
                if (is.keyboardEvent(event) && event.which !== 32) {
                    return;
                }

                event.preventDefault();
                event.stopPropagation();

                menuItem.checked = true;

                switch (type) {
                    case 'language':
                        this.currentTrack = Number(value);
                        break;

                    case 'quality':
                        this.quality = value;
                        break;

                    case 'speed':
                        this.speed = parseFloat(value);
                        break;

                    default:
                        break;
                }

                controls.showMenuPanel.call(this, 'home', is.keyboardEvent(event));
            },
            type,
            false,
        );

        controls.bindMenuItemShortcuts.call(this, menuItem, type);

        list.appendChild(menuItem);
    },

    // Format a time for display
    formatTime(time = 0, inverted = false) {
        // Bail if the value isn't a number
        if (!is.number(time)) {
            return time;
        }

        // Always display hours if duration is over an hour
        const forceHours = getHours(this.duration) > 0;

        return formatTime(time, forceHours, inverted);
    },

    // Update the displayed time
    updateTimeDisplay(target = null, time = 0, inverted = false) {
        // Bail if there's no element to display or the value isn't a number
        if (!is.element(target) || !is.number(time)) {
            return;
        }

        // eslint-disable-next-line no-param-reassign
        target.innerText = controls.formatTime(time, inverted);
    },

    // Update volume UI and storage
    updateVolume() {
        if (!this.supported.ui) {
            return;
        }

        // Update range
        if (is.element(this.elements.inputs.volume)) {
            controls.setRange.call(this, this.elements.inputs.volume, this.muted ? 0 : this.volume);
        }

        // Update mute state
        if (is.element(this.elements.buttons.mute)) {
            this.elements.buttons.mute.pressed = this.muted || this.volume === 0;
        }
    },

    // Update seek value and lower fill
    setRange(target, value = 0) {
        if (!is.element(target)) {
            return;
        }

        // eslint-disable-next-line
        target.value = value;

        // Webkit range fill
        controls.updateRangeFill.call(this, target);
    },

    // Update <progress> elements
    updateProgress(event) {
        if (!this.supported.ui || !is.event(event)) {
            return;
        }

        let value = 0;

        const setProgress = (target, input) => {
            const value = is.number(input) ? input : 0;
            const progress = is.element(target) ? target : this.elements.display.buffer;

            // Update value and label
            if (is.element(progress)) {
                progress.value = value;

                // Update text label inside
                const label = progress.getElementsByTagName('span')[0];
                if (is.element(label)) {
                    label.childNodes[0].nodeValue = value;
                }
            }
        };

        if (event) {
            switch (event.type) {
                // Video playing
                case 'timeupdate':
                case 'seeking':
                case 'seeked':
                    value = getPercentage(this.currentTime, this.duration);

                    // Set seek range value only if it's a 'natural' time event
                    if (event.type === 'timeupdate') {
                        controls.setRange.call(this, this.elements.inputs.seek, value);
                    }

                    break;

                // Check buffer status
                case 'playing':
                case 'progress':
                    setProgress(this.elements.display.buffer, this.buffered * 100);

                    break;

                default:
                    break;
            }
        }
    },

    // Webkit polyfill for lower fill range
    updateRangeFill(target) {
        // Get range from event if event passed
        const range = is.event(target) ? target.target : target;

        // Needs to be a valid <input type='range'>
        if (!is.element(range) || range.getAttribute('type') !== 'range') {
            return;
        }

        // Set aria values for https://github.com/sampotts/plyr/issues/905
        if (matches(range, this.config.selectors.inputs.seek)) {
            range.setAttribute('aria-valuenow', this.currentTime);
            const currentTime = controls.formatTime(this.currentTime);
            const duration = controls.formatTime(this.duration);
            const format = i18n.get('seekLabel', this.config);
            range.setAttribute(
                'aria-valuetext',
                format.replace('{currentTime}', currentTime).replace('{duration}', duration),
            );
        } else if (matches(range, this.config.selectors.inputs.volume)) {
            const percent = range.value * 100;
            range.setAttribute('aria-valuenow', percent);
            range.setAttribute('aria-valuetext', `${percent.toFixed(1)}%`);
        } else {
            range.setAttribute('aria-valuenow', range.value);
        }

        // WebKit only
        if (!browser.isWebkit) {
            return;
        }

        // Set CSS custom property
        range.style.setProperty('--value', `${range.value / range.max * 100}%`);
    },

    // Update hover tooltip for seeking
    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;
        }

        // Calculate percentage
        let percent = 0;
        const clientRect = this.elements.progress.getBoundingClientRect();
        const visible = `${this.config.classNames.tooltip}--visible`;

        const toggle = toggle => {
            toggleClass(this.elements.display.seekTooltip, visible, toggle);
        };

        // Hide on touch
        if (this.touch) {
            toggle(false);
            return;
        }

        // Determine percentage, if already visible
        if (is.event(event)) {
            percent = 100 / clientRect.width * (event.pageX - clientRect.left);
        } else if (hasClass(this.elements.display.seekTooltip, visible)) {
            percent = parseFloat(this.elements.display.seekTooltip.style.left, 10);
        } else {
            return;
        }

        // 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(pane => !pane.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)) {
            const isMenuItem = popup.contains(input.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 = document.getElementById(`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 link
    setDownloadLink() {
        const button = this.elements.buttons.download;

        // Bail if no button
        if (!is.element(button)) {
            return;
        }

        // Set download link
        button.setAttribute('href', this.download);
    },

    // Build the default HTML
    // TODO: Set order based on order in the config.controls array?
    create(data) {
        // Create the container
        const container = createElement('div', getAttributesFromSelector(this.config.selectors.controls.wrapper));

        // Restart button
        if (this.config.controls.includes('restart')) {
            container.appendChild(controls.createButton.call(this, 'restart'));
        }

        // Rewind button
        if (this.config.controls.includes('rewind')) {
            container.appendChild(controls.createButton.call(this, 'rewind'));
        }

        // Play/Pause button
        if (this.config.controls.includes('play')) {
            container.appendChild(controls.createButton.call(this, 'play'));
        }

        // Fast forward button
        if (this.config.controls.includes('fast-forward')) {
            container.appendChild(controls.createButton.call(this, 'fast-forward'));
        }

        // Progress
        if (this.config.controls.includes('progress')) {
            const progress = createElement('div', getAttributesFromSelector(this.config.selectors.progress));

            // Seek range slider
            progress.appendChild(
                controls.createRange.call(this, 'seek', {
                    id: `plyr-seek-${data.id}`,
                }),
            );

            // Buffer progress
            progress.appendChild(controls.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;
            container.appendChild(this.elements.progress);
        }

        // Media current time display
        if (this.config.controls.includes('current-time')) {
            container.appendChild(controls.createTime.call(this, 'currentTime'));
        }

        // Media duration display
        if (this.config.controls.includes('duration')) {
            container.appendChild(controls.createTime.call(this, 'duration'));
        }

        // Volume controls
        if (this.config.controls.includes('mute') || this.config.controls.includes('volume')) {
            const volume = createElement('div', {
                class: 'plyr__volume',
            });

            // Toggle mute button
            if (this.config.controls.includes('mute')) {
                volume.appendChild(controls.createButton.call(this, 'mute'));
            }

            // Volume range control
            if (this.config.controls.includes('volume')) {
                // Set the attributes
                const attributes = {
                    max: 1,
                    step: 0.05,
                    value: this.config.volume,
                };

                // Create the volume range slider
                volume.appendChild(
                    controls.createRange.call(
                        this,
                        'volume',
                        extend(attributes, {
                            id: `plyr-volume-${data.id}`,
                        }),
                    ),
                );

                this.elements.volume = volume;
            }

            container.appendChild(volume);
        }

        // Toggle captions button
        if (this.config.controls.includes('captions')) {
            container.appendChild(controls.createButton.call(this, 'captions'));
        }

        // Settings button / menu
        if (this.config.controls.includes('settings') && !is.empty(this.config.settings)) {
            const control = createElement('div', {
                class: 'plyr__menu',
                hidden: '',
            });

            control.appendChild(
                controls.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
                controls.bindMenuItemShortcuts.call(this, menuItem, type);

                // Show menu on click
                on(menuItem, 'click', () => {
                    controls.showMenuPanel.call(this, type, false);
                });

                const flex = createElement('span', null, i18n.get(type, this.config));

                const value = createElement('span', {
                    class: this.config.classNames.menu.value,
                });

                // Speed contains HTML entities
                value.innerHTML = data[type];

                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(
                    pane,
                    'keydown',
                    event => {
                        // We only care about <-
                        if (event.which !== 37) {
                            return;
                        }

                        // Prevent seek
                        event.preventDefault();
                        event.stopPropagation();

                        // Show the respective menu
                        controls.showMenuPanel.call(this, 'home', true);
                    },
                    false,
                );

                // Go back via button click
                on(backButton, 'click', () => {
                    controls.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);
            control.appendChild(popup);
            container.appendChild(control);

            this.elements.settings.popup = popup;
            this.elements.settings.menu = control;
        }

        // Picture in picture button
        if (this.config.controls.includes('pip') && support.pip) {
            container.appendChild(controls.createButton.call(this, 'pip'));
        }

        // Airplay button
        if (this.config.controls.includes('airplay') && support.airplay) {
            container.appendChild(controls.createButton.call(this, 'airplay'));
        }

        // Download button
        if (this.config.controls.includes('download')) {
            const attributes = {
                element: 'a',
                href: this.download,
                target: '_blank',
            };

            const { download } = this.config.urls;

            if (!is.url(download) && this.isEmbed) {
                extend(attributes, {
                    icon: `logo-${this.provider}`,
                    label: this.provider,
                });
            }

            container.appendChild(controls.createButton.call(this, 'download', attributes));
        }

        // Toggle fullscreen button
        if (this.config.controls.includes('fullscreen')) {
            container.appendChild(controls.createButton.call(this, 'fullscreen'));
        }

        // Larger overlaid play button
        if (this.config.controls.includes('play-large')) {
            this.elements.container.appendChild(controls.createButton.call(this, 'play-large'));
        }

        this.elements.controls = container;

        // Set available quality levels
        if (this.isHTML5) {
            controls.setQualityMenu.call(this, html5.getQualityOptions.call(this));
        }

        controls.setSpeedMenu.call(this);

        return container;
    },

    // Insert controls
    inject() {
        // Sprite
        if (this.config.loadSprite) {
            const icon = controls.getIconUrl.call(this);

            // Only load external sprite using AJAX
            if (icon.cors) {
                loadSprite(icon.url, 'sprite-plyr');
            }
        }

        // Create a unique ID
        this.id = Math.floor(Math.random() * 10000);

        // Null by default
        let container = null;
        this.elements.controls = null;

        // Set template properties
        const props = {
            id: this.id,
            seektime: this.config.seekTime,
            title: this.config.title,
        };
        let update = true;

        // If function, run it and use output
        if (is.function(this.config.controls)) {
            this.config.controls = this.config.controls.call(this, props);
        }

        // Convert falsy controls to empty array (primarily for empty strings)
        if (!this.config.controls) {
            this.config.controls = [];
        }

        if (is.element(this.config.controls) || is.string(this.config.controls)) {
            // HTMLElement or Non-empty string passed as the option
            container = this.config.controls;
        } else {
            // Create controls
            container = controls.create.call(this, {
                id: this.id,
                seektime: this.config.seekTime,
                speed: this.speed,
                quality: this.quality,
                captions: captions.getLabel.call(this),
                // TODO: Looping
                // loop: 'None',
            });
            update = false;
        }

        // Replace props with their value
        const replace = input => {
            let result = input;

            Object.entries(props).forEach(([key, value]) => {
                result = replaceAll(result, `{${key}}`, value);
            });

            return result;
        };

        // Update markup
        if (update) {
            if (is.string(this.config.controls)) {
                container = replace(container);
            } else if (is.element(container)) {
                container.innerHTML = replace(container.innerHTML);
            }
        }

        // Controls container
        let target;

        // Inject to custom location
        if (is.string(this.config.selectors.controls.container)) {
            target = document.querySelector(this.config.selectors.controls.container);
        }

        // Inject into the container by default
        if (!is.element(target)) {
            target = this.elements.container;
        }

        // Inject controls HTML (needs to be before captions, hence "afterbegin")
        const insertMethod = is.element(container) ? 'insertAdjacentElement' : 'insertAdjacentHTML';
        target[insertMethod]('afterbegin', container);

        // Find the elements if need be
        if (!is.element(this.elements.controls)) {
            controls.findElements.call(this);
        }

        // Add pressed property to buttons
        if (!is.empty(this.elements.buttons)) {
            const addProperty = button => {
                const className = this.config.classNames.controlPressed;
                Object.defineProperty(button, 'pressed', {
                    enumerable: true,
                    get() {
                        return hasClass(button, className);
                    },
                    set(pressed = false) {
                        toggleClass(button, className, pressed);
                    },
                });
            };

            // Toggle classname when pressed property is set
            Object.values(this.elements.buttons)
                .filter(Boolean)
                .forEach(button => {
                    if (is.array(button) || is.nodeList(button)) {
                        Array.from(button).filter(Boolean).forEach(addProperty);
                    } else {
                        addProperty(button);
                    }
                });
        }

        // Edge sometimes doesn't finish the paint so force a redraw
        if (window.navigator.userAgent.includes('Edge')) {
            repaint(target);
        }

        // Setup tooltips
        if (this.config.tooltips.controls) {
            const { classNames, selectors } = this.config;
            const selector = `${selectors.controls.wrapper} ${selectors.labels} .${classNames.hidden}`;
            const labels = getElements.call(this, selector);

            Array.from(labels).forEach(label => {
                toggleClass(label, this.config.classNames.hidden, false);
                toggleClass(label, this.config.classNames.tooltip, true);
            });
        }
    },
};

export default controls;
