index.js

const {
  EventEmitter
} = require('events');
const {
  joinVoiceChannel,
  createAudioPlayer,
  entersState,
  AudioPlayerStatus,
  VoiceConnectionStatus,
} = require('@discordjs/voice');

/**
 * Player was destroyed
 *
 * @event Player#playerDestroyed
 * @type {Object}
 * @property {GuildID} guildId
 */

/**
 * Player skipped track
 *
 * @event Player#skippedTrack
 * @type {object}
 * @property {Player} player
 */

/**
 * Player created event.
 *
 * @event Player#playerCreated
 * @type {object}
 * @property {Player} player
 */

/**
 * Player joined a voice channel
 *
 * @event Player#joinedVoiceChannel
 * @type {object}
 * @property {Player} player
 */

/**
 * Player started playing a track.
 *
 * @event Player#playingTrack
 * @type {object}
 * @property {Player} player
 * @property {Track} track - The track that was started
 */

/**
 * Player started playing a track.
 *
 * @event Player#trackEnded
 * @type {object}
 * @property {Player} player
 * @property {Track} track - The track that was started
 */

/**
 * The eventemitter
 * @type {EventEmitter}
 */
exports.events = new EventEmitter();

/**
 * Object containing all players by guildID
 * @type {Object}
 */
module.exports.players = {}

/** The player, handles joining, playback and queueing of tracks. It shouldn't be necessary to create this object as getPlayer will create it for you.*/
class Player {
  /**
   * @param {GuildID} guildId
   */
  constructor(guildId) {
    exports.players[guildId] = this
    this.guildId = guildId
    this.queue = []
    this.playing = false
    this.paused = false
    this.repeat = "none"
    this.player = createAudioPlayer()
    this.player.on("error", console.log)

    // Code that starts the next track on end
    this.player.on("stateChange", (state) => {
      if (this.playing && !this.paused) {
        if (state.status = "idle") {
          this.playing = false
          exports.events.emit('trackEnded', this, this.queue[0])
          if (this.startNextTrack(true) == null) {}
        }
      }
    })

    exports.events.emit('playerCreated', this);
  }

  /**
   * Join a voice channel
   * @param {VoiceChannel} voiceChannel - discord.js voiceChannel object to join
   */
  async join(voiceChannel) {
    try {
      let channel = voiceChannel.id
      let guildId = voiceChannel.guild.id

      const connection = joinVoiceChannel({
        channelId: channel,
        guildId: guildId,
        adapterCreator: voiceChannel.guild.voiceAdapterCreator,
        selfDeaf: true
      });

      await entersState(connection, VoiceConnectionStatus.Ready, 30e3)
      connection.subscribe(this.player)

      if (this.connection) this.connection.destroy()
      this.channel = channel
      this.guildId = guildId
      this.connection = connection

      exports.events.emit('joinedVoiceChannel', this);

    } catch (error) {
      connection.destroy();
      return error;
    }
  }

  /**
   * Join a voice channel with extra options
   * @param  {VoiceChannel}  voiceChannel
   * @param  {Boolean} [forceJoin=false] - If it should try to connect, even when connected
   */
  async joinNice(voiceChannel, forceJoin = false) {
    if (!this.connection | forceJoin) return await this.join(voiceChannel)
  }

  /**
   * Pauses the playback
   * @return {Promise<boolean>} True if it could pause
   */
  async pause() {
    this.paused = true
    return this.player.pause()
  }

  /**
   * Set the repeat mode
   * @param {String} [mode="none"]  "none|all|single"
   */
  setRepeat(mode="none") {
    this.repeat = mode;
  }

  /**
   * Unpauses the playback
   * @return {Promise<boolean>} True if it could unpause
   */
  async unpause() {
    const success = this.player.unpause()
    this.paused = false
    return success
  }

  /**
   * Starts the next track
   * @param  {Boolean} [shift=false] If the player should play the next element in the queue, or restart the current. Ignored if player.repeat is "single"
   * @param  {Boolean} [preloadNext=true] If the player should preload the next track for quicker playback.
   * @return {Promise<?Track>} The track that started playing.
   */
  async startNextTrack(shift = false, preloadNext = true) {
    let prev = undefined
    if (shift && this.repeat != "single") prev = this.queue.shift();
    if (this.repeat == "single") {
      this.queue[0].preloadedResource = undefined;
    }

    if (this.repeat == "all" && prev) {
      prev.preloadedResource = undefined;
      this.queue.push(prev)
    }

    if (this.queue[0]) {
      let resource = await this.queue[0].play()
      this.player.play(resource)
      await entersState(this.player, AudioPlayerStatus.Playing, 5e3);
      this.playing = true

      exports.events.emit('playingTrack', this, this.queue[0])
      if (this.queue[1] && preloadNext) {
        this.queue[1].preload()
      }
      return this.queue[0]
    } else {
      exports.removePlayer(this.guildId)
    }
    return
  }

  /**
   * Skip the currently playing track.
   */
  skipCurrentTrack() {
    if (this.queue[0]) {
      exports.events.emit('skippedTrack', this)
      this.player.stop()
    }
  }

  /**
   * Adds the tracks to the queue, can be configured to start or create the player
   * @param {GuildID} guildId
   * @param {Track|Track[]}  track - The track(s) to add
   * @param {Boolean} [dontStartPlayer=false] - Set to true if the player should start playing if nothing else is
   * @param {Boolean} [create=true] - Set to false if the player shouldn't be created
   * @return {Object} Info about added
   */
  async addTrackNice(track, unshift = false, dontStartPlayer = false) {
    const info = {playlist: false, started: false, track}
    if (track.constructor !== Array) {
      this.addTrack(track, unshift)
    } else {
      track.forEach((item) => {
        this.addTrack(item, unshift)
        info.playlist = true
      });
    }

    if (!this.playing && !dontStartPlayer) {
      this.startNextTrack()
      info.started = true
    }
    return info
  }

  /**
   * Add a track to the queue
   * @param  {Track} track - The track to be added to the queue
   * @return {Number} The queue length
   */
  addTrack(track, unshift = false) {
    this.queue[unshift ? 'unshift' : 'push'](track)
    exports.events.emit('addedTrack', this, track)
    return this.queue.length
  }

  /**
   * Shuffles the queue
   */
  shuffleQueue() {
    let playing = this.queue.shift()
    this.queue.sort(() => Math.random() - 0.5);
    this.queue.unshift(playing)
  }

  /**
   * Gets the current queue
   * @param  {Number}  [start=0]   Index to start from
   * @param  {Number}  [stop=-1]   Index to end on
   * @param  {Boolean} [info=true] Include the whole Track object, or just the info
   * @return {object}  The queue object
   */
  getQueue(start = 0, stop = -1, metadata = true) {
    const queue = { start, stop, length: this.queue.length, metadata, queue: [] }

    this.queue.slice(start, stop).forEach((track) => {
      metadata ?
      queue.queue.push(track.metadata)
      :
      queue.queue.push(track)
    })

    return queue
  }

  /**
   * Removes a track from the queue using uid's
   * @param {String} uid a tracks unique id
   */
  removeFromQueue(uid) {
    this.queue = this.queue.filter(item => {return (item.uid !== uid)})
  }
}

exports.Player = Player

/**
 * Deletes the player for the specified guildID
 * @param {GuildID} guildId
 */
exports.removePlayer = (guildId) => {
  if (exports.players[guildId]) {
    exports.events.emit('playerDestroyed', guildId)
    exports.players[guildId].connection.destroy()
    exports.players[guildId].player.stop()
    delete exports.players[guildId]
  }
}

/**
 * Gets the player for the specified guildID
 * @param {GuildID} guildId
 * @param {Boolean} [createPlayer=false] - If the player should be created if it doesn't exist, will create of type class if specified
 * @return {Player}
 */
exports.getPlayer = (guildId, createPlayer = false) => {
  if (exports.players[guildId]) {
    return exports.players[guildId]
  } else {
    if (createPlayer) {
      return new exports.Player(guildId)
    } else {
      return
    }
  }
}