// ==========================================================================
// Plyr
// plyr.js v3.7.8
// 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 html5 from './html5';
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 { silencePromise } from './utils/promise';
import { getAspectRatio, reduceAspectRatio, setAspectRatio, validateAspectRatio } 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 (_) {
          return {};
        }
      })(),
    );

    // Elements cache
    this.elements = {
      container: null,
      fullscreen: 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.values(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);

    // 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');
      wrap(this.media, this.elements.container);
    }

    // Migrate custom properties from media to container (so they work 😉)
    ui.migrateStyles.call(this);

    // 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 fullscreen
    this.fullscreen = new Fullscreen(this);

    // 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 ads if provided
    if (this.config.ads.enabled) {
      this.ads = new Ads(this);
    }

    // Autoplay if required
    if (this.isHTML5 && this.config.autoplay) {
      this.once('canplay', () => silencePromise(this.play()));
    }

    // 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(() => silencePromise(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 null;
    }

    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) {
      return this.play();
    }

    return 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 -= 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 += 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} input - 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(() => {
      if (this.media) {
        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') || this.media.getAttribute('data-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) || !validateAspectRatio(input)) {
      this.debug.error(`Invalid aspect ratio specified (${input})`);
      return;
    }

    this.config.ratio = reduceAspectRatio(input);

    setAspectRatio.call(this);
  }

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

  /**
   * 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} input - Caption index
   */
  set currentTrack(input) {
    captions.set.call(this, input, false);
    captions.setup.call(this);
  }

  /**
   * 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} input - 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;
  }

  /**
   * Sets the preview thumbnails for the current source
   */
  setPreviewThumbnails(thumbnailSource) {
    if (this.previewThumbnails && this.previewThumbnails.loaded) {
      this.previewThumbnails.destroy();
      this.previewThumbnails = null;
    }

    Object.assign(this.config.previewThumbnails, thumbnailSource);

    // Create new instance if it is still enabled
    if (this.config.previewThumbnails.enabled) {
      this.previewThumbnails = new PreviewThumbnails(this);
    }
  }

  /**
   * 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 &&
        is.array(this.config.controls) &&
        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);

        // Cancel current network requests
        html5.cancelRequests.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) => support.mime.call(this, type);

  /**
   * Check for support
   * @param {String} type - Player type (audio/video)
   * @param {String} provider - Provider (html5/youtube/vimeo)
   */
  static supported(type, provider) {
    return support.check(type, provider);
  }

  /**
   * 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;
