// ==========================================================================
// Plyr
// plyr.js v3.5.6
// https://github.com/sampotts/plyr
// License: The MIT License (MIT)
// ==========================================================================

import captions from './captions';
import defaults from './config/defaults';
import { pip } from './config/states';
import { getProviderByUrl, providers, types } from './config/types';
import Console from './console';
import controls from './controls';
import Fullscreen from './fullscreen';
import Listeners from './listeners';
import media from './media';
import Ads from './plugins/ads';
import PreviewThumbnails from './plugins/preview-thumbnails';
import source from './source';
import Storage from './storage';
import support from './support';
import ui from './ui';
import { closest } from './utils/arrays';
import { createElement, hasClass, removeElement, replaceElement, toggleClass, wrap } from './utils/elements';
import { off, on, once, triggerEvent, unbindListeners } from './utils/events';
import is from './utils/is';
import loadSprite from './utils/load-sprite';
import { clamp } from './utils/numbers';
import { cloneDeep, extend } from './utils/objects';
import { getAspectRatio, reduceAspectRatio, setAspectRatio, validateRatio } from './utils/style';
import { parseUrl } from './utils/urls';

// Private properties
// TODO: Use a WeakMap for private globals
// const globals = new WeakMap();

// Plyr instance
class Plyr {
    constructor(target, options) {
        this.timers = {};

        // State
        this.ready = false;
        this.loading = false;
        this.failed = false;

        // Touch device
        this.touch = support.touch;

        // Set the media element
        this.media = target;

        // String selector passed
        if (is.string(this.media)) {
            this.media = document.querySelectorAll(this.media);
        }

        // jQuery, NodeList or Array passed, use first element
        if ((window.jQuery && this.media instanceof jQuery) || is.nodeList(this.media) || is.array(this.media)) {
            // eslint-disable-next-line
            this.media = this.media[0];
        }

        // Set config
        this.config = extend(
            {},
            defaults,
            Plyr.defaults,
            options || {},
            (() => {
                try {
                    return JSON.parse(this.media.getAttribute('data-plyr-config'));
                } catch (e) {
                    return {};
                }
            })(),
        );

        // Elements cache
        this.elements = {
            container: null,
            captions: null,
            buttons: {},
            display: {},
            progress: {},
            inputs: {},
            settings: {
                popup: null,
                menu: null,
                panels: {},
                buttons: {},
            },
        };

        // Captions
        this.captions = {
            active: null,
            currentTrack: -1,
            meta: new WeakMap(),
        };

        // Fullscreen
        this.fullscreen = {
            active: false,
        };

        // Options
        this.options = {
            speed: [],
            quality: [],
        };

        // Debugging
        // TODO: move to globals
        this.debug = new Console(this.config.debug);

        // Log config options and support
        this.debug.log('Config', this.config);
        this.debug.log('Support', support);

        // We need an element to setup
        if (is.nullOrUndefined(this.media) || !is.element(this.media)) {
            this.debug.error('Setup failed: no suitable element passed');
            return;
        }

        // Bail if the element is initialized
        if (this.media.plyr) {
            this.debug.warn('Target already setup');
            return;
        }

        // Bail if not enabled
        if (!this.config.enabled) {
            this.debug.error('Setup failed: disabled by config');
            return;
        }

        // Bail if disabled or no basic support
        // You may want to disable certain UAs etc
        if (!support.check().api) {
            this.debug.error('Setup failed: no support');
            return;
        }

        // Cache original element state for .destroy()
        const clone = this.media.cloneNode(true);
        clone.autoplay = false;
        this.elements.original = clone;

        // Set media type based on tag or data attribute
        // Supported: video, audio, vimeo, youtube
        const type = this.media.tagName.toLowerCase();
        // Embed properties
        let iframe = null;
        let url = null;

        // Different setup based on type
        switch (type) {
            case 'div':
                // Find the frame
                iframe = this.media.querySelector('iframe');

                // <iframe> type
                if (is.element(iframe)) {
                    // Detect provider
                    url = parseUrl(iframe.getAttribute('src'));
                    this.provider = getProviderByUrl(url.toString());

                    // Rework elements
                    this.elements.container = this.media;
                    this.media = iframe;

                    // Reset classname
                    this.elements.container.className = '';

                    // Get attributes from URL and set config
                    if (url.search.length) {
                        const truthy = ['1', 'true'];

                        if (truthy.includes(url.searchParams.get('autoplay'))) {
                            this.config.autoplay = true;
                        }
                        if (truthy.includes(url.searchParams.get('loop'))) {
                            this.config.loop.active = true;
                        }

                        // TODO: replace fullscreen.iosNative with this playsinline config option
                        // YouTube requires the playsinline in the URL
                        if (this.isYouTube) {
                            this.config.playsinline = truthy.includes(url.searchParams.get('playsinline'));
                            this.config.youtube.hl = url.searchParams.get('hl'); // TODO: Should this be setting language?
                        } else {
                            this.config.playsinline = true;
                        }
                    }
                } else {
                    // <div> with attributes
                    this.provider = this.media.getAttribute(this.config.attributes.embed.provider);

                    // Remove attribute
                    this.media.removeAttribute(this.config.attributes.embed.provider);
                }

                // Unsupported or missing provider
                if (is.empty(this.provider) || !Object.keys(providers).includes(this.provider)) {
                    this.debug.error('Setup failed: Invalid provider');
                    return;
                }

                // Audio will come later for external providers
                this.type = types.video;

                break;

            case 'video':
            case 'audio':
                this.type = type;
                this.provider = providers.html5;

                // Get config from attributes
                if (this.media.hasAttribute('crossorigin')) {
                    this.config.crossorigin = true;
                }
                if (this.media.hasAttribute('autoplay')) {
                    this.config.autoplay = true;
                }
                if (this.media.hasAttribute('playsinline') || this.media.hasAttribute('webkit-playsinline')) {
                    this.config.playsinline = true;
                }
                if (this.media.hasAttribute('muted')) {
                    this.config.muted = true;
                }
                if (this.media.hasAttribute('loop')) {
                    this.config.loop.active = true;
                }

                break;

            default:
                this.debug.error('Setup failed: unsupported type');
                return;
        }

        // Check for support again but with type
        this.supported = support.check(this.type, this.provider, this.config.playsinline);

        // If no support for even API, bail
        if (!this.supported.api) {
            this.debug.error('Setup failed: no support');
            return;
        }

        this.eventListeners = [];

        // Create listeners
        this.listeners = new Listeners(this);

        // Setup local storage for user settings
        this.storage = new Storage(this);

        // Store reference
        this.media.plyr = this;

        // Wrap media
        if (!is.element(this.elements.container)) {
            this.elements.container = createElement('div', { tabindex: 0 });
            wrap(this.media, this.elements.container);
        }

        // Add style hook
        ui.addStyleHook.call(this);

        // Setup media
        media.setup.call(this);

        // Listen for events if debugging
        if (this.config.debug) {
            on.call(this, this.elements.container, this.config.events.join(' '), event => {
                this.debug.log(`event: ${event.type}`);
            });
        }

        // Setup interface
        // If embed but not fully supported, build interface now to avoid flash of controls
        if (this.isHTML5 || (this.isEmbed && !this.supported.ui)) {
            ui.build.call(this);
        }

        // Container listeners
        this.listeners.container();

        // Global listeners
        this.listeners.global();

        // Setup fullscreen
        this.fullscreen = new Fullscreen(this);

        // Setup ads if provided
        if (this.config.ads.enabled) {
            this.ads = new Ads(this);
        }

        // Autoplay if required
        if (this.isHTML5 && this.config.autoplay) {
            setTimeout(() => this.play(), 10);
        }

        // Seek time will be recorded (in listeners.js) so we can prevent hiding controls for a few seconds after seek
        this.lastSeekTime = 0;

        // Setup preview thumbnails if enabled
        if (this.config.previewThumbnails.enabled) {
            this.previewThumbnails = new PreviewThumbnails(this);
        }
    }

    // ---------------------------------------
    // API
    // ---------------------------------------

    /**
     * Types and provider helpers
     */
    get isHTML5() {
        return this.provider === providers.html5;
    }

    get isEmbed() {
        return this.isYouTube || this.isVimeo;
    }

    get isYouTube() {
        return this.provider === providers.youtube;
    }

    get isVimeo() {
        return this.provider === providers.vimeo;
    }

    get isVideo() {
        return this.type === types.video;
    }

    get isAudio() {
        return this.type === types.audio;
    }

    /**
     * Play the media, or play the advertisement (if they are not blocked)
     */
    play() {
        if (!is.function(this.media.play)) {
            return null;
        }

        // Intecept play with ads
        if (this.ads && this.ads.enabled) {
            this.ads.managerPromise.then(() => this.ads.play()).catch(() => this.media.play());
        }

        // Return the promise (for HTML5)
        return this.media.play();
    }

    /**
     * Pause the media
     */
    pause() {
        if (!this.playing || !is.function(this.media.pause)) {
            return;
        }

        this.media.pause();
    }

    /**
     * Get playing state
     */
    get playing() {
        return Boolean(this.ready && !this.paused && !this.ended);
    }

    /**
     * Get paused state
     */
    get paused() {
        return Boolean(this.media.paused);
    }

    /**
     * Get stopped state
     */
    get stopped() {
        return Boolean(this.paused && this.currentTime === 0);
    }

    /**
     * Get ended state
     */
    get ended() {
        return Boolean(this.media.ended);
    }

    /**
     * Toggle playback based on current status
     * @param {Boolean} input
     */
    togglePlay(input) {
        // Toggle based on current state if nothing passed
        const toggle = is.boolean(input) ? input : !this.playing;

        if (toggle) {
            this.play();
        } else {
            this.pause();
        }
    }

    /**
     * Stop playback
     */
    stop() {
        if (this.isHTML5) {
            this.pause();
            this.restart();
        } else if (is.function(this.media.stop)) {
            this.media.stop();
        }
    }

    /**
     * Restart playback
     */
    restart() {
        this.currentTime = 0;
    }

    /**
     * Rewind
     * @param {Number} seekTime - how far to rewind in seconds. Defaults to the config.seekTime
     */
    rewind(seekTime) {
        this.currentTime = this.currentTime - (is.number(seekTime) ? seekTime : this.config.seekTime);
    }

    /**
     * Fast forward
     * @param {Number} seekTime - how far to fast forward in seconds. Defaults to the config.seekTime
     */
    forward(seekTime) {
        this.currentTime = this.currentTime + (is.number(seekTime) ? seekTime : this.config.seekTime);
    }

    /**
     * Seek to a time
     * @param {Number} input - where to seek to in seconds. Defaults to 0 (the start)
     */
    set currentTime(input) {
        // Bail if media duration isn't available yet
        if (!this.duration) {
            return;
        }

        // Validate input
        const inputIsValid = is.number(input) && input > 0;

        // Set
        this.media.currentTime = inputIsValid ? Math.min(input, this.duration) : 0;

        // Logging
        this.debug.log(`Seeking to ${this.currentTime} seconds`);
    }

    /**
     * Get current time
     */
    get currentTime() {
        return Number(this.media.currentTime);
    }

    /**
     * Get buffered
     */
    get buffered() {
        const { buffered } = this.media;

        // YouTube / Vimeo return a float between 0-1
        if (is.number(buffered)) {
            return buffered;
        }

        // HTML5
        // TODO: Handle buffered chunks of the media
        // (i.e. seek to another section buffers only that section)
        if (buffered && buffered.length && this.duration > 0) {
            return buffered.end(0) / this.duration;
        }

        return 0;
    }

    /**
     * Get seeking status
     */
    get seeking() {
        return Boolean(this.media.seeking);
    }

    /**
     * Get the duration of the current media
     */
    get duration() {
        // Faux duration set via config
        const fauxDuration = parseFloat(this.config.duration);
        // Media duration can be NaN or Infinity before the media has loaded
        const realDuration = (this.media || {}).duration;
        const duration = !is.number(realDuration) || realDuration === Infinity ? 0 : realDuration;

        // If config duration is funky, use regular duration
        return fauxDuration || duration;
    }

    /**
     * Set the player volume
     * @param {Number} value - must be between 0 and 1. Defaults to the value from local storage and config.volume if not set in storage
     */
    set volume(value) {
        let volume = value;
        const max = 1;
        const min = 0;

        if (is.string(volume)) {
            volume = Number(volume);
        }

        // Load volume from storage if no value specified
        if (!is.number(volume)) {
            volume = this.storage.get('volume');
        }

        // Use config if all else fails
        if (!is.number(volume)) {
            ({ volume } = this.config);
        }

        // Maximum is volumeMax
        if (volume > max) {
            volume = max;
        }
        // Minimum is volumeMin
        if (volume < min) {
            volume = min;
        }

        // Update config
        this.config.volume = volume;

        // Set the player volume
        this.media.volume = volume;

        // If muted, and we're increasing volume manually, reset muted state
        if (!is.empty(value) && this.muted && volume > 0) {
            this.muted = false;
        }
    }

    /**
     * Get the current player volume
     */
    get volume() {
        return Number(this.media.volume);
    }

    /**
     * Increase volume
     * @param {Boolean} step - How much to decrease by (between 0 and 1)
     */
    increaseVolume(step) {
        const volume = this.media.muted ? 0 : this.volume;
        this.volume = volume + (is.number(step) ? step : 0);
    }

    /**
     * Decrease volume
     * @param {Boolean} step - How much to decrease by (between 0 and 1)
     */
    decreaseVolume(step) {
        this.increaseVolume(-step);
    }

    /**
     * Set muted state
     * @param {Boolean} mute
     */
    set muted(mute) {
        let toggle = mute;

        // Load muted state from storage
        if (!is.boolean(toggle)) {
            toggle = this.storage.get('muted');
        }

        // Use config if all else fails
        if (!is.boolean(toggle)) {
            toggle = this.config.muted;
        }

        // Update config
        this.config.muted = toggle;

        // Set mute on the player
        this.media.muted = toggle;
    }

    /**
     * Get current muted state
     */
    get muted() {
        return Boolean(this.media.muted);
    }

    /**
     * Check if the media has audio
     */
    get hasAudio() {
        // Assume yes for all non HTML5 (as we can't tell...)
        if (!this.isHTML5) {
            return true;
        }

        if (this.isAudio) {
            return true;
        }

        // Get audio tracks
        return (
            Boolean(this.media.mozHasAudio) ||
            Boolean(this.media.webkitAudioDecodedByteCount) ||
            Boolean(this.media.audioTracks && this.media.audioTracks.length)
        );
    }

    /**
     * Set playback speed
     * @param {Number} speed - the speed of playback (0.5-2.0)
     */
    set speed(input) {
        let speed = null;

        if (is.number(input)) {
            speed = input;
        }

        if (!is.number(speed)) {
            speed = this.storage.get('speed');
        }

        if (!is.number(speed)) {
            speed = this.config.speed.selected;
        }

        // Clamp to min/max
        const { minimumSpeed: min, maximumSpeed: max } = this;
        speed = clamp(speed, min, max);

        // Update config
        this.config.speed.selected = speed;

        // Set media speed
        setTimeout(() => {
            this.media.playbackRate = speed;
        }, 0);
    }

    /**
     * Get current playback speed
     */
    get speed() {
        return Number(this.media.playbackRate);
    }

    /**
     * Get the minimum allowed speed
     */
    get minimumSpeed() {
        if (this.isYouTube) {
            // https://developers.google.com/youtube/iframe_api_reference#setPlaybackRate
            return Math.min(...this.options.speed);
        }

        if (this.isVimeo) {
            // https://github.com/vimeo/player.js/#setplaybackrateplaybackrate-number-promisenumber-rangeerrorerror
            return 0.5;
        }

        // https://stackoverflow.com/a/32320020/1191319
        return 0.0625;
    }

    /**
     * Get the maximum allowed speed
     */
    get maximumSpeed() {
        if (this.isYouTube) {
            // https://developers.google.com/youtube/iframe_api_reference#setPlaybackRate
            return Math.max(...this.options.speed);
        }

        if (this.isVimeo) {
            // https://github.com/vimeo/player.js/#setplaybackrateplaybackrate-number-promisenumber-rangeerrorerror
            return 2;
        }

        // https://stackoverflow.com/a/32320020/1191319
        return 16;
    }

    /**
     * Set playback quality
     * Currently HTML5 & YouTube only
     * @param {Number} input - Quality level
     */
    set quality(input) {
        const config = this.config.quality;
        const options = this.options.quality;

        if (!options.length) {
            return;
        }

        let quality = [
            !is.empty(input) && Number(input),
            this.storage.get('quality'),
            config.selected,
            config.default,
        ].find(is.number);

        let updateStorage = true;

        if (!options.includes(quality)) {
            const value = closest(options, quality);
            this.debug.warn(`Unsupported quality option: ${quality}, using ${value} instead`);
            quality = value;

            // Don't update storage if quality is not supported
            updateStorage = false;
        }

        // Update config
        config.selected = quality;

        // Set quality
        this.media.quality = quality;

        // Save to storage
        if (updateStorage) {
            this.storage.set({ quality });
        }
    }

    /**
     * Get current quality level
     */
    get quality() {
        return this.media.quality;
    }

    /**
     * Toggle loop
     * TODO: Finish fancy new logic. Set the indicator on load as user may pass loop as config
     * @param {Boolean} input - Whether to loop or not
     */
    set loop(input) {
        const toggle = is.boolean(input) ? input : this.config.loop.active;
        this.config.loop.active = toggle;
        this.media.loop = toggle;

        // Set default to be a true toggle
        /* const type = ['start', 'end', 'all', 'none', 'toggle'].includes(input) ? input : 'toggle';

        switch (type) {
            case 'start':
                if (this.config.loop.end && this.config.loop.end <= this.currentTime) {
                    this.config.loop.end = null;
                }
                this.config.loop.start = this.currentTime;
                // this.config.loop.indicator.start = this.elements.display.played.value;
                break;

            case 'end':
                if (this.config.loop.start >= this.currentTime) {
                    return this;
                }
                this.config.loop.end = this.currentTime;
                // this.config.loop.indicator.end = this.elements.display.played.value;
                break;

            case 'all':
                this.config.loop.start = 0;
                this.config.loop.end = this.duration - 2;
                this.config.loop.indicator.start = 0;
                this.config.loop.indicator.end = 100;
                break;

            case 'toggle':
                if (this.config.loop.active) {
                    this.config.loop.start = 0;
                    this.config.loop.end = null;
                } else {
                    this.config.loop.start = 0;
                    this.config.loop.end = this.duration - 2;
                }
                break;

            default:
                this.config.loop.start = 0;
                this.config.loop.end = null;
                break;
        } */
    }

    /**
     * Get current loop state
     */
    get loop() {
        return Boolean(this.media.loop);
    }

    /**
     * Set new media source
     * @param {Object} input - The new source object (see docs)
     */
    set source(input) {
        source.change.call(this, input);
    }

    /**
     * Get current source
     */
    get source() {
        return this.media.currentSrc;
    }

    /**
     * Get a download URL (either source or custom)
     */
    get download() {
        const { download } = this.config.urls;

        return is.url(download) ? download : this.source;
    }

    /**
     * Set the download URL
     */
    set download(input) {
        if (!is.url(input)) {
            return;
        }

        this.config.urls.download = input;

        controls.setDownloadUrl.call(this);
    }

    /**
     * Set the poster image for a video
     * @param {String} input - the URL for the new poster image
     */
    set poster(input) {
        if (!this.isVideo) {
            this.debug.warn('Poster can only be set for video');
            return;
        }

        ui.setPoster.call(this, input, false).catch(() => {});
    }

    /**
     * Get the current poster image
     */
    get poster() {
        if (!this.isVideo) {
            return null;
        }

        return this.media.getAttribute('poster');
    }

    /**
     * Get the current aspect ratio in use
     */
    get ratio() {
        if (!this.isVideo) {
            return null;
        }

        const ratio = reduceAspectRatio(getAspectRatio.call(this));

        return is.array(ratio) ? ratio.join(':') : ratio;
    }

    /**
     * Set video aspect ratio
     */
    set ratio(input) {
        if (!this.isVideo) {
            this.debug.warn('Aspect ratio can only be set for video');
            return;
        }

        if (!is.string(input) || !validateRatio(input)) {
            this.debug.error(`Invalid aspect ratio specified (${input})`);
            return;
        }

        this.config.ratio = input;

        setAspectRatio.call(this);
    }

    /**
     * Set the autoplay state
     * @param {Boolean} input - Whether to autoplay or not
     */
    set autoplay(input) {
        const toggle = is.boolean(input) ? input : this.config.autoplay;
        this.config.autoplay = toggle;
    }

    /**
     * Get the current autoplay state
     */
    get autoplay() {
        return Boolean(this.config.autoplay);
    }

    /**
     * Toggle captions
     * @param {Boolean} input - Whether to enable captions
     */
    toggleCaptions(input) {
        captions.toggle.call(this, input, false);
    }

    /**
     * Set the caption track by index
     * @param {Number} - Caption index
     */
    set currentTrack(input) {
        captions.set.call(this, input, false);
    }

    /**
     * Get the current caption track index (-1 if disabled)
     */
    get currentTrack() {
        const { toggled, currentTrack } = this.captions;
        return toggled ? currentTrack : -1;
    }

    /**
     * Set the wanted language for captions
     * Since tracks can be added later it won't update the actual caption track until there is a matching track
     * @param {String} - Two character ISO language code (e.g. EN, FR, PT, etc)
     */
    set language(input) {
        captions.setLanguage.call(this, input, false);
    }

    /**
     * Get the current track's language
     */
    get language() {
        return (captions.getCurrentTrack.call(this) || {}).language;
    }

    /**
     * Toggle picture-in-picture playback on WebKit/MacOS
     * TODO: update player with state, support, enabled
     * TODO: detect outside changes
     */
    set pip(input) {
        // Bail if no support
        if (!support.pip) {
            return;
        }

        // Toggle based on current state if not passed
        const toggle = is.boolean(input) ? input : !this.pip;

        // Toggle based on current state
        // Safari
        if (is.function(this.media.webkitSetPresentationMode)) {
            this.media.webkitSetPresentationMode(toggle ? pip.active : pip.inactive);
        }

        // Chrome
        if (is.function(this.media.requestPictureInPicture)) {
            if (!this.pip && toggle) {
                this.media.requestPictureInPicture();
            } else if (this.pip && !toggle) {
                document.exitPictureInPicture();
            }
        }
    }

    /**
     * Get the current picture-in-picture state
     */
    get pip() {
        if (!support.pip) {
            return null;
        }

        // Safari
        if (!is.empty(this.media.webkitPresentationMode)) {
            return this.media.webkitPresentationMode === pip.active;
        }

        // Chrome
        return this.media === document.pictureInPictureElement;
    }

    /**
     * Trigger the airplay dialog
     * TODO: update player with state, support, enabled
     */
    airplay() {
        // Show dialog if supported
        if (support.airplay) {
            this.media.webkitShowPlaybackTargetPicker();
        }
    }

    /**
     * Toggle the player controls
     * @param {Boolean} [toggle] - Whether to show the controls
     */
    toggleControls(toggle) {
        // Don't toggle if missing UI support or if it's audio
        if (this.supported.ui && !this.isAudio) {
            // Get state before change
            const isHidden = hasClass(this.elements.container, this.config.classNames.hideControls);
            // Negate the argument if not undefined since adding the class to hides the controls
            const force = typeof toggle === 'undefined' ? undefined : !toggle;
            // Apply and get updated state
            const hiding = toggleClass(this.elements.container, this.config.classNames.hideControls, force);

            // Close menu
            if (hiding && this.config.controls.includes('settings') && !is.empty(this.config.settings)) {
                controls.toggleMenu.call(this, false);
            }

            // Trigger event on change
            if (hiding !== isHidden) {
                const eventName = hiding ? 'controlshidden' : 'controlsshown';
                triggerEvent.call(this, this.media, eventName);
            }

            return !hiding;
        }

        return false;
    }

    /**
     * Add event listeners
     * @param {String} event - Event type
     * @param {Function} callback - Callback for when event occurs
     */
    on(event, callback) {
        on.call(this, this.elements.container, event, callback);
    }

    /**
     * Add event listeners once
     * @param {String} event - Event type
     * @param {Function} callback - Callback for when event occurs
     */
    once(event, callback) {
        once.call(this, this.elements.container, event, callback);
    }

    /**
     * Remove event listeners
     * @param {String} event - Event type
     * @param {Function} callback - Callback for when event occurs
     */
    off(event, callback) {
        off(this.elements.container, event, callback);
    }

    /**
     * Destroy an instance
     * Event listeners are removed when elements are removed
     * http://stackoverflow.com/questions/12528049/if-a-dom-element-is-removed-are-its-listeners-also-removed-from-memory
     * @param {Function} callback - Callback for when destroy is complete
     * @param {Boolean} soft - Whether it's a soft destroy (for source changes etc)
     */
    destroy(callback, soft = false) {
        if (!this.ready) {
            return;
        }

        const done = () => {
            // Reset overflow (incase destroyed while in fullscreen)
            document.body.style.overflow = '';

            // GC for embed
            this.embed = null;

            // If it's a soft destroy, make minimal changes
            if (soft) {
                if (Object.keys(this.elements).length) {
                    // Remove elements
                    removeElement(this.elements.buttons.play);
                    removeElement(this.elements.captions);
                    removeElement(this.elements.controls);
                    removeElement(this.elements.wrapper);

                    // Clear for GC
                    this.elements.buttons.play = null;
                    this.elements.captions = null;
                    this.elements.controls = null;
                    this.elements.wrapper = null;
                }

                // Callback
                if (is.function(callback)) {
                    callback();
                }
            } else {
                // Unbind listeners
                unbindListeners.call(this);

                // Replace the container with the original element provided
                replaceElement(this.elements.original, this.elements.container);

                // Event
                triggerEvent.call(this, this.elements.original, 'destroyed', true);

                // Callback
                if (is.function(callback)) {
                    callback.call(this.elements.original);
                }

                // Reset state
                this.ready = false;

                // Clear for garbage collection
                setTimeout(() => {
                    this.elements = null;
                    this.media = null;
                }, 200);
            }
        };

        // Stop playback
        this.stop();

        // Clear timeouts
        clearTimeout(this.timers.loading);
        clearTimeout(this.timers.controls);
        clearTimeout(this.timers.resized);

        // Provider specific stuff
        if (this.isHTML5) {
            // Restore native video controls
            ui.toggleNativeControls.call(this, true);

            // Clean up
            done();
        } else if (this.isYouTube) {
            // Clear timers
            clearInterval(this.timers.buffering);
            clearInterval(this.timers.playing);

            // Destroy YouTube API
            if (this.embed !== null && is.function(this.embed.destroy)) {
                this.embed.destroy();
            }

            // Clean up
            done();
        } else if (this.isVimeo) {
            // Destroy Vimeo API
            // then clean up (wait, to prevent postmessage errors)
            if (this.embed !== null) {
                this.embed.unload().then(done);
            }

            // Vimeo does not always return
            setTimeout(done, 200);
        }
    }

    /**
     * Check for support for a mime type (HTML5 only)
     * @param {String} type - Mime type
     */
    supports(type) {
        return support.mime.call(this, type);
    }

    /**
     * Check for support
     * @param {String} type - Player type (audio/video)
     * @param {String} provider - Provider (html5/youtube/vimeo)
     * @param {Boolean} inline - Where player has `playsinline` sttribute
     */
    static supported(type, provider, inline) {
        return support.check(type, provider, inline);
    }

    /**
     * Load an SVG sprite into the page
     * @param {String} url - URL for the SVG sprite
     * @param {String} [id] - Unique ID
     */
    static loadSprite(url, id) {
        return loadSprite(url, id);
    }

    /**
     * Setup multiple instances
     * @param {*} selector
     * @param {Object} options
     */
    static setup(selector, options = {}) {
        let targets = null;

        if (is.string(selector)) {
            targets = Array.from(document.querySelectorAll(selector));
        } else if (is.nodeList(selector)) {
            targets = Array.from(selector);
        } else if (is.array(selector)) {
            targets = selector.filter(is.element);
        }

        if (is.empty(targets)) {
            return null;
        }

        return targets.map(t => new Plyr(t, options));
    }
}

Plyr.defaults = cloneDeep(defaults);

export default Plyr;
