Source: player.js

const EventEmitter2 = require('eventemitter2').EventEmitter2
const arrayBufferToAudioBuffer = require('arraybuffer-to-audiobuffer');
const {scaleLinear} = require('d3-scale')
const randomInt = require('random-int')

const PlayerEventTypes = require('./constants/PlayerEventTypes')

/**
 * Class reprensenting a Player
 * @namespace Player
 * @example
 * const player = new Player()
 */
class Player {
  /**
   * @desc Create a Player
   */
  constructor() {
    const ee = new EventEmitter2({
      wildcard: true
    })

    this.emit = ee.emit.bind(ee)

  /**
   * @method on
   * @memberof Player
   * @instance
   * @desc Listen for player events
   * @param {String} eventName - name of even
   * @param {Function} callback - called when the event occurs
   * @example
   * player.on(Player.EventTypes.PLAY, () => {
   *
   * })
   * @example
   * player.on(Player.EventTypes.PLAYBACK_RATE, () => {
   *
   * })
   */
    this.on = ee.on.bind(ee)

    window.AudioContext = window.AudioContext || window.webkitAudioContext;

    this._queue = []
    this._currentQueueIndex = 0
    this._currentSource = null
    this._currentBuffer = null
    this._context = new AudioContext()
    this._playbackRate = 1

    this._playbackRateScale = scaleLinear()
    .domain([0.75, 2])
    .range([0.75, 2])
    .clamp(true)

    this._volume = 1
    this._maxVolume = 1
    this._volumeScale = scaleLinear()
    .domain([0, 1])
    .range([0, this._maxVolume])
    .clamp(true)
    this._maxVolumeScale = scaleLinear()
    .domain([0, 1])
    .range([0, 1])
    .clamp(true)

    this.random = false
    this.repeat = false

    this.isPlaying = false

    this.isReady = true
    this._log('Player ready');
    this.emit(PlayerEventTypes.READY);
  }

  _log(type, message) {
    if (type && !message) {
      message = type;
      type = 'log';
    }

    setTimeout(() => {
      this.emit(PlayerEventTypes.LOG, message);
    }, 0);

    if (this._debug) {
      console[type](message);
    }
  }

  /**
   * getCurrentUrl
   * @desc Get currently playing audio source url
   * @return {String}
   * @example
   * const url = player.getCurrentUrl()
   */
  getCurrentUrl() {
    const source = this._queue[this._currentQueueIndex]

    if (typeof source === 'string') {
      return source
    }

    return null
  }

  /**
   * emptyQueue
   * @desc Empties queue
   * @return {Promise}
   * @example
   * player.emptyQueue()
   */
  emptyQueue() {
    return new Promise((resolve, reject) => {
      this._queue = [];
      this._currentQueueIndex = 0
      this._audio = null;
      this._currentBuffer = null;
      this._currentSource = null;
      this.stop()

      this._log('Empty queue');
      this.emit(PlayerEventTypes.EMPTY_QUEUE);
      resolve();
    });
  }

  /**
   * enqueue
   * @desc Enqueues an audio source to play
   * @param {(DataView|Uint8Array|AudioBuffer|ArrayBuffer|String)} source - an audio source to play
   * @return {Promise}
   * @example
   * const url = 'https://example.com/audio.mp3'
   *
   * player.enqueue(url)
   * @example
   * player.enqueue(audioBuffer)
   * @example
   * player.enqueue(arrayBuffer)
   * @example
   * player.enqueue(blob)
   */
  enqueue(source) {
    return new Promise((resolve, reject) => {
      if (Array.isArray(source)) {
        return source.forEach(x => this.enqueue(x))
      }

      if (!source) {
        const error = new Error('argument cannot be empty.');
        this._log(error);
        return reject(error);
      }

      const stringType = ({}).toString.call(source).replace(/\[.*\s(\w+)\]/, '$1');

      const proceed = (audioBuffer) => {
        this._queue.push(audioBuffer);
        this._log('Enqueue audio');
        this.emit(PlayerEventTypes.ENQUEUE);
        return resolve(audioBuffer);
      };

      if (stringType === 'DataView' || stringType === 'Uint8Array') {
        return arrayBufferToAudioBuffer(source.buffer, this._context)
        .then(proceed);
      } else if (stringType === 'AudioBuffer') {
        return proceed(source);
      } else if (stringType === 'ArrayBuffer') {
        return arrayBufferToAudioBuffer(source, this._context)
        .then(proceed);
      } else if (stringType === 'String') {
        return proceed(source);
      } else {
        const error = new Error('Invalid type.');
        this.emit('error', error);
        return reject(error);
      }
    });
  }

  /**
   * deqeue
   * @desc Deques an audio source from queue
   * @return {Promise}
   * @example
   * player.deque()
   */
  deque() {
    return new Promise((resolve, reject) => {
      if (this.random) {
        this._currentQueueIndex = randomInt(0, this._queue.length-1)
      }

      const source = this._queue[this._currentQueueIndex]

      if (source) {
        this._log('Deque audio');
        this.emit(PlayerEventTypes.DEQUE);
        return resolve(source);
      }

      return reject(new Error('no source to play'));
    });
  }

  /**
   * play
   * @desc Plays current audio source in queue
   * @return {Promise}
   * @example
   * player.play()
   */
  play() {
    return new Promise((resolve, reject) => {
      this.isPlaying = true

      // if paused then resume
      if (this._context.state === 'suspended') {
        this._context.resume();

        this._log('Play audio');
        this.emit(PlayerEventTypes.PLAY);
        resolve();

      // if paused then resume
      } else if (this._audio && this._audio.paused) {
        this._log('Play audio');
        this.emit(PlayerEventTypes.PLAY);
        this._audio.play();
        resolve();

      // if paused then resume
      } else {
        return this.deque()
        .then(audioBuffer => {
          this._log('Play audio');
          this.emit(PlayerEventTypes.PLAY);
          if (typeof audioBuffer === 'string') {
            return this.playUrl(audioBuffer);
          }
          return this.playAudioBuffer(audioBuffer);
        })
        .then(resolve);
      }
    });
  }

  /**
   * playQueue
   * @desc Start playing audio sources in queue
   * @return {Promise}
   * @example
   * player.playQueue()
   */
  playQueue() {
    return this.play().then(() => {
      if (this._queue.length) {
        return this.playQueue();
      }
    });
  }

  /**
   * stop
   * @desc Stop playing current audio source
   * @return {Promise}
   * @example
   * player.stop()
   */
  stop() {
    return new Promise((resolve, reject) => {
      this.isPlaying = false

      if (this._currentSource) {
        this._currentSource.onended = function() {};
        this._currentSource.stop();
      }

      if (this._audio) {
        this._audio.onended = function() {};
        this._audio.currentTime = 0;
        this._audio.pause();
      }

      this._log('Stop audio');
      this.emit(PlayerEventTypes.STOP);
    });
  }

  /**
   * pause
   * @desc Pause playing current audio source
   * @return {Promise}
   * @example
   * player.pause()
   */
  pause() {
    return new Promise((resolve, reject) => {
      this.isPlaying = false

      if (this._currentSource && this._context.state === 'running') {
        this._context.suspend();
      }

      if (this._audio) {
        this._audio.pause();
      }

      this._log('Pause audio');
      this.emit(PlayerEventTypes.PAUSE);
    });
  }

  /**
   * replay
   * @desc Replay current audio source
   * @return {Promise}
   * @example
   * player.replay()
   */
  replay() {
    return new Promise((resolve, reject) => {
      if (this._currentBuffer) {
        this._log('Replay audio');
        this.emit(PlayerEventTypes.REPLAY);

        if (this._context.state === 'suspended') {
          this._context.resume();
        }

        if (this._currentSource) {
          this._currentSource.stop();
          this._currentSource.onended = function() {};
        }
        return this.playAudioBuffer(this._currentBuffer);
      } else if (this._audio) {
        this._log('Replay audio');
        this.emit(PlayerEventTypes.REPLAY);
        return this.playUrl(this._audio.src);
      } else {
        const error = new Error('No audio source loaded.');
        this.emit('error', error)
        reject();
      }
    });
  }

  /**
   * playBlob
   * @desc Play an audio Blob
   * @param {Blob} blob - audio blob
   * @return {Promise}
   * @example
   * const blob = new Blob([dataView], {
   *   type: 'audio/wav'
   * })
   *
   * player.playBlob(blob)
   */
  playBlob(blob) {
    return new Promise((resolve, reject) => {
      if (!blob) {
        reject();
      }

      const objectUrl = URL.createObjectURL(blob);
      const audio = new Audio();
      audio.src = objectUrl;
      this._currentBuffer = null;
      this._currentSource = null;
      this._audio = audio;
      this._audio.playbackRate = this._playbackRate

      audio.onended = () => {
        this._log('Audio ended');
        this.emit(PlayerEventTypes.ENDED);
        resolve();
      };

      audio.onerror = (error) => {
        this.emit('error', error);
        reject(error);
      };

      audio.onload = (event) => {
        URL.revokeObjectUrl(objectUrl);
      };

      audio.play();
    });
  }

  /**
   * playAudioBuffer
   * @desc Play an AudioBuffer
   * @param {AudioBuffer} audioBuffer - an AudioBuffer
   * @return {Promise}
   * @example
   * player.playAudioBuffer(audioBuffer)
   */
  playAudioBuffer(buffer) {
    return new Promise((resolve, reject) => {
      if (!buffer) {
        reject();
      }

      const source = this._context.createBufferSource();
      source.buffer = buffer;
      source.connect(this._context.destination);
      source.start(0);
      this._currentBuffer = buffer;
      this._currentSource = source;
      this._audio = null;

      source.onended = (event) => {
        this._log('Audio ended');
        this.emit(PlayerEventTypes.ENDED);
        resolve();
      };

      source.onerror = (error) => {
        this.emit('error', error);
        reject(error);
      };
    });
  }

  /**
   * getCurrentAudioBuffer
   * @desc Return current audio buffer playing
   * @return {AudioBuffer}
   * @example
   * player.getCurrentAudioBuffer()
   */
  getCurrentAudioBuffer() {
    return Promise.resolve(this._currentBuffer)
  }

  /**
   * playUrl
   * @desc Play an MP3 url
   * @param {String} url - MP3 url
   * @return {Promise}
   * @example
   * const url = 'https://example.com/audio.mp3'
   *
   * player.playUrl(url)
   */
  playUrl(url) {
    return new Promise((resolve, reject) => {
      const usingAudioTag = false

      if (usingAudioTag) {
        const audio = new Audio();
        audio.src = url;
        this._currentBuffer = null;
        this._currentSource = null;
        this._audio = audio;
        this._audio.playbackRate = this._playbackRate

        audio.onended = (event) => {
          this._log('Audio ended');
          this.emit(PlayerEventTypes.ENDED);
          resolve();
        };

        audio.onerror = (error) => {
          this.emit('error', error);
          reject(error);
        };

        audio.play();
      } else {
        return this.getAudioDataFromUrl(url)
        .then(buffer => {
          return this.playAudioBuffer(buffer)
        })
      }
    });
  }

  /**
   * getAudioDataFromUrl
   * @desc Get the binary audio data from an audio source url in ArrayBuffer form
   * @param {String} url - audio source url
   * @return {Promise} arrayBuffer
   * @example
   * const url = 'https://example.com/audio.mp3'
   *
   * player.getAudioDataFromUrl()
   * .then(arrayBuffer => {
   *
   * })
   */
  getAudioDataFromUrl(url) {
    return new Promise((resolve, reject) => {
      const request = new XMLHttpRequest()
      request.open('GET', url, true)
      request.responseType = 'arraybuffer'

      request.onload = () => {
        const audioData = request.response

        this._context.decodeAudioData(audioData, (buffer) => {
          resolve(buffer)
        },
        (error) => {
          reject(error)
        })
      }

      request.send()
    })
  }

  /**
   * next
   * @desc Play next audio source in queue
   * @return {Promise}
   * @example
   * player.next()
   */
  next() {
    return new Promise((resolve, reject) => {
      const isLast = (this._currentQueueIndex === this._queue.length-1)

      if (isLast) {
        this._currentQueueIndex = -1
      }

      const source = this._queue[this._currentQueueIndex + 1]

      if (source) {
        this._currentQueueIndex++

        this._log('Next audio');
        this.emit(PlayerEventTypes.NEXT);

        const continuePlaying = this.isPlaying

        this.stop()
        this._audio = null
        resolve(source)

        if (continuePlaying) {
          this.play()
        }
      }

      return reject(new Error('no source to play'));
    });
  }

  /**
   * previous
   * @desc Play previous audio source in queue
   * @return {Promise}
   * @example
   * player.previous()
   */
  previous() {
    return new Promise((resolve, reject) => {
      const source = this._queue[this._currentQueueIndex - 1]

      if (source) {
        this._currentQueueIndex--
        this._log('Previous audio');
        this.emit(PlayerEventTypes.PREVIOUS);

        const continuePlaying = this.isPlaying

        this.stop()
        this._audio = null

        resolve(source)

        if (continuePlaying) {
          this.play()
        }
      }

      return reject(new Error('no source to play'))
    });
  }

  /**
   * setRandom
   * @desc Enable to disable random playback
   * @param {Boolean} enabled - boolean to enable random playback
   * @return {Promise}
   * @example
   * player.setRandom(true)
   */
  setRandom(enabled) {
    this._log(`Set random: ${enabled}`)
    this.emit(PlayerEventTypes.RANDOM)
    this.random = enabled
  }

  /**
   * setRepeat
   * @desc Enable to disable repeat mode. Repeat mode replays the audio sources once the entire queue has finished playing.
   * @param {Boolean} enabled - boolean to enable repeat mode
   * @return {Promise}
   * @example
   * player.setRepeat(true)
   */
  setRepeat(enabled) {
    this._log(`Set repeat: ${enabled}`)
    this.emit(PlayerEventTypes.REPEAT)
    this.repeat = enabled
  }

  /**
   * hasNext
   * @desc Return true if there's an audio source to play next in queue
   * @return {Boolean} hasNext
   * @example
   * const hasNext = player.hasNext()
   */
  hasNext() {
    //const hasNext = this._queue.length > 1 && this._currentQueueIndex < this._queue.length-1
    const hasNext = this._queue.length > 1
    return hasNext
  }

  /**
   * hasPrevious
   * @desc Return true if there's a previous audio source in queue
   * @return {Boolean} hasPrevious
   * @example
   * const hasPrevious = player.hasPrevious()
   */
  hasPrevious() {
    const hasPrevious = this._queue.length > 1 && this._currentQueueIndex > 0
    return hasPrevious
  }

  /**
   * setPlaybackRate
   * @desc Set the plaback rate speed, range 0.75-2
   * @param {Number} playbackRate - new playback rate
   * @return {Promise}
   * @example
   * player.setPlaybackRate(2)
   */
  setPlaybackRate(rate) {
    return new Promise((resolve, reject) => {
      this._playbackRate = this._playbackRateScale(rate)

      if (this._audio) {
        this._audio.playbackRate = this._playbackRate
      }

      if (this._currentSource) {
        this._currentSource.playbackRate.value = this._playbackRate
      }

      this._log(`Set playback rate: ${this._playbackRate}`);
      this.emit(PlayerEventTypes.PLAYBACK_RATE, this._playbackRate);

      resolve(this._playbackRate)
    })
  }

  /**
   * getPlaybackRate
   * @desc Get the current plaback rate speed
   * @return {Number} playback rate speed
   * @example
   * const playbackRate = player.getPlaybackRate()
   */
  getPlaybackRate() {
    return this._playbackRate
  }

  /**
   * setVolume
   * @desc Set volume, range 0-1
   * @param {Number} volume - volume value
   * @return {Promise}
   * @example
   * player.setVolume(0.9)
   */
  setVolume(volume) {
    return new Promise((resolve, reject) => {
      this._volume = this._volumeScale(volume)

      if (this._audio) {
        this._audio.volume = this._volume;
      }

      this._log(`Set volume: ${this._volume}`);
      this.emit(PlayerEventTypes.VOLUME, this._volume);

      resolve(this._volume)
    })
  }

  /**
   * getVolume
   * @desc Get current volume value
   * @return {Number} volume - current volume value
   * @example
   * player.getVolume()
   */
  getVolume() {
    return this._volume
  }

  /**
   * setMaxVolume
   * @desc Set the maximum volume limit. For example if max volume is set to 0.6, then when volume is scaled from 0-0.6, meaning that volume level at 1 will play at 0.6
   * @param {Number} maxVolume - max volume, range 0-1
   * @return {Promise}
   * @example
   * player.setMaxVolume(0.8)
   */
  setMaxVolume(maxVolume) {
    return new Promise((resolve, reject) => {
      this._maxVolume = this._maxVolumeScale(maxVolume)
      this._volumeScale = scaleLinear()
      .domain([0, 1])
      .range([0, this._maxVolume])
      .clamp(true)

      this.setVolume(this._volume)

      this._log(`Set max volume: ${this._volume}`);
      this.emit(PlayerEventTypes.MAX_VOLUME, this._maxVolume);
      resolve()
    })
  }

  /**
   * getMaxVolume
   * @desc Get max volume value
   * @return {Number} maxVolume - max volume value
   * @example
   * player.getMaxVolume()
   */
  getMaxVolume() {
    return this._maxVolume
  }

  /**
   * setMuted
   * @desc Set volume level to muted
   * @param {Boolean} enabled - boolean to enable mute
   * @return {Promise}
   * @example
   * player.setMuted(true)
   */
  // TODO
  setMuted(enabled) {
    this.muted = !!enabled

    return Promise.resolve(this.muted)
  }

  /**
   * getCurrentTime
   * @desc Return elapsed time in seconds since the audio source started playing
   * @return {Number} time - current time
   * @example
   * player.getCurrentTime()
   */
  getCurrentTime() {
    if (this._context) {
      return this._context.currentTime
    }

    if (this._audio) {
      return this._audio.currentTime
    }

    return 0
  }

  /**
   * seekTo
   * @desc Seek to a specified time in audio source
   * @param {Number} seconds - number of seconds to seek to
   * @return {Promise}
   * @example
   * const seconds = 45
   *
   * player.seekTo(seconds)
   */
  seekTo(seconds) {
    if (this._context) {
      this._context.currentTime = seconds
    }

    if (this._audio) {
      this._audio.currentTime = seconds
    }

    return Promise.resolve()
  }

  /**
   * getDuration
   * @desc Get duration of audio source
   * @return {Number} duration - duration of audio source
   * @example
   * player.getDuration()
   */
  getDuration() {
    return 0
  }

  /**
   * @static EventTypes
   * @type {Object}
   * @desc Return event types
   * @return {Object} eventTypes - all player event types
   * @example
   * const EventTypes = Player.EventTypes
   *
   * {
      LOG: 'log',
      ERROR: 'error',
      READY: 'ready',
      PLAY: 'play',
      REPLAY: 'replay',
      PAUSE: 'pause',
      STOP: 'pause',
      NEXT: 'next',
      PREVIOUS: 'previous',
      RANDOM: 'random',
      REPEAT: 'repeat',
      PLAYBACK_RATE: 'playbackRate',
      VOLUME: 'volume',
      MAX_VOLUME: 'maxVolume',
      MUTED: 'muted',
      ENDED: 'ended',
      ENQUEUE: 'enqueue',
      DEQUE: 'deque',
      EMPTY_QUEUE: 'emptyQueue',
      STATE_CHANGE: 'stateChange'
   * }
   */
  static get EventTypes() {
    return PlayerEventTypes
  }
}

module.exports = Player