const ytdl = require('ytdl-core') const { Manager } = require('./manager') const { Playlist } = require('./playlist'); const LOOP_VALUES = { full: 2, all: 2, every: 2, playlist: 2, 2: 2, track: 1, one: 1, 1: 1, none: 0, zero: 0, 0: 0, } const LOOP_NAMES = { 0: 'None', 1: 'Current track', 2: 'Full playlist', } /** * @class * @extends Manager */ class PlayerManager extends Manager { #voice #dispatcher #playlist #loop #current_stream get_playlist() { return this.#playlist } set_playlist(playlist) { this.#playlist = playlist return this } constructor(gmanager, guild_id, config) { super(...arguments) this.#voice = null this.#dispatcher = null this.#playlist = new Playlist() this.#current_stream = null this.#loop = config.default_loop } /** * @private * @description ensure that the current stream is removed */ #destroyStream() { if (this.#current_stream) { this.#current_stream.destroy() this.#current_stream = null } } // https://www.youtube.com/watch?v=Yom8nNqmxvQ /** * Stream the given track from YouTube * @param {Track} */ async stream(track) { if (!track) return this track.update_play_count() const stream = ytdl(track.get_url(), { filter: 'audioonly' }) this.#dispatcher = this.#voice.play(stream) this.#destroyStream() this.#current_stream = stream this.#dispatcher.on('finish', () => { if (this.#loop === 1) { this.stream(track) } else { this.next() } }) return this } /** * @returns true if this is connected to a voice chan */ is_connected() { return Boolean(this.#voice) } /** * Connects to a voice channel and ensure basic configuration of self (deaf etc.) * * @param {Discord.VoiceChannel} channel to be joined */ async connect(channel) { this.#voice = await channel.join() this.#voice.voice.setSelfDeaf(true) } async leave({ message, params }) { if (this.#voice) { this.#voice.channel.leave() this.#destroyStream() this.#playlist = new Playlist() } } async clear({ message, params }) { if (this.get_playlist().is_touched()) { const confirm_msg = await message.channel.send(":warning: Confirm you want to reset without saving or !register.") confirm_msg.react("💾") confirm_msg.react("⏏️") confirm_msg.awaitReactions(async (r, u) => { if (r.emoji.name === "💾" && u.id !== confirm_msg.author.id) { const current_playlist = this.get_manager('playlists') if (await current_playlist.register({ message, params: [] })) { this.#clear_confirmed({ message, params }) } } if (r.emoji.name === "⏏️" && u.id !== confirm_msg.author.id) { this.#clear_confirmed({ message, params }) } }, { max: 1 }) } } async #clear_confirmed({ message, params }) { this.#destroyStream() this.#playlist = new Playlist() } async shuffle({ message, params }) { this.#playlist.shuffle() message.channel.send('Shuffled ! :twisted_rightwards_arrows:') } /** * Change the loop flag to give it 3 possible values: 0 1 2. * 0 means no looping * 1 means loop the current track * 2 means loop the whole track */ async toggle_loop({ message, params }) { if (params[0] === undefined) { this.#loop = (this.#loop + 1) % 3 message.channel.send(`:white_check_mark: Looping set to "${this.get_loop_name()}"`) } else if (LOOP_VALUES[params[0]] !== undefined) { this.#loop = LOOP_VALUES[params[0]] message.channel.send(`:white_check_mark: Looping set to "${this.get_loop_name()}"`) } else { message.channel.send(`:warning: No such loop as "${params[0]}"`) } return this } get_loop_name() { return LOOP_NAMES[this.#loop] } async add({ message, params }) { if (!message.member?.voice?.channel) { message.channel.send('Not connected to voice') // cannot connect } else { await this.connect(message.member.voice.channel) if (params[0]) { const track = /^https?:\/\//.test(params[0]) ? await this.get_manager('resource').get_metadata(params[0]) : (await this.get_manager('resource').get_search_results(params))[0] if (!track) { message.channel.send(':warning: Something went wrong. That\'s not supposed to happen...') } else { message.react('👌') this.#playlist.push(track) } } if (!this.#current_stream) { const track = this.#playlist.get_current_track() this.stream(track) } } } /** * params[0] is the id of the track to unqueue * TODO ensure consistency with index/id displayed... */ async remove({ message, params }) { const id_to_remove = Number(params[0]) const index_to_remove = id_to_remove - 1; if (!isNaN(id_to_remove)) { const track = this.#playlist.get_track_by_index(index_to_remove) if (track && this.#playlist.delete(index_to_remove)) { message.channel.send(`:white_check_mark: The track ${id_to_remove} \`${track.get_full_title()}\` has been removed`) } else { message.channel.send(`:warning: The track ${id_to_remove} has not been removed or found`) } } } async next() { const track = this.#playlist.next({ loop: this.#loop }).get_current_track(); if (track) { await this.stream(track) } else { this.#destroyStream() } } async prev() { const track = this.#playlist.previous({ loop: this.#loop }).get_current_track(); if (track) { await this.stream(track) } else { this.#destroyStream() } } async goto({ message, params }) { if (this.#playlist.goto(parseInt(params[0]))) { this.stream(this.#playlist.get_current_track()) message.react('👌') } else { message.channel.send(`:warning: Could not find track at index \`${params}\`.`) } } async list({ message, params }) { if (this.#playlist.is_empty()) { message.channel.send("Empty playlist") } else { // TODO: check 2000 characters limit const string_message = `Current playlist: (${this.#playlist.get_name()}, looping: ${this.get_loop_name()})\n` + "```json\n" + this.#playlist.map_tracks((t, id, _, current) =>(current === t ? '(*) ' : '') + `${id + 1} ${t}`).join("\n") + "```\n"; message.channel.send(string_message.slice(0, 2000)) } } } module.exports = { PlayerManager }