diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..43b6d49 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,22 @@ +module.exports = { + env: { + commonjs: true, + es6: true, + node: true + }, + extends: [ + 'standard' + ], + globals: { + Atomics: 'readonly', + SharedArrayBuffer: 'readonly' + }, + parserOptions: { + ecmaVersion: 11 + }, + rules: { + indent: ['error', 4], + 'no-async-promise-executor': 'off', + 'no-unused-vars': 'off' + } +} diff --git a/.github/workflows/main.yml b/.github/workflows/jsdoc-deploy.yml similarity index 81% rename from .github/workflows/main.yml rename to .github/workflows/jsdoc-deploy.yml index bd1818b..1610ebd 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/jsdoc-deploy.yml @@ -15,14 +15,14 @@ jobs: - name: Build uses: andstor/jsdoc-action@v1 with: - source_dir: ./src output_dir: ./docs config_file: .jsdoc.json - template_name: Androz2091/minami + template: Androz2091/minami front_page: README.md - name: Deploy uses: peaceiris/actions-gh-pages@v3 with: personal_token: ${{ secrets.ACTIONS_DEPLOY_KEY }} - publish_dir: ./docs \ No newline at end of file + publish_dir: ./docs + cname: discord-player.js.org diff --git a/.gitignore b/.gitignore index dc10c68..a74d490 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ node_modules poc.js package-lock.json yarn.lock +docs +.vscode diff --git a/README.md b/README.md index 04b96c5..0b488e0 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ **Note**: this module uses recent discordjs features and requires discord.js version 12. -Discord Player is a powerful [Node.js](https://nodejs.org) module that allows you to easily implement music commands. **Everything** is customizable, and everything is done to simplify your work **without limiting you**! +Discord Player is a powerful [Node.js](https://nodejs.org) module that allows you to easily implement music commands. **Everything** is customizable, and everything is done to simplify your work **without limiting you**! It doesn't require any api key, as it uses **scraping**. ## Installation @@ -14,15 +14,24 @@ Discord Player is a powerful [Node.js](https://nodejs.org) module that allows yo npm install --save discord-player ``` -Install **opusscript** or **@discordjs/opus**: +Install **@discordjs/opus**: ```sh -npm install --save opusscript +npm install --save @discordjs/opus ``` Install [FFMPEG](https://www.ffmpeg.org/download.html) and you're done! -## Player +## Features + +🤘 Easy to use! +🎸 You can apply some cool filters (bassboost, reverse, 8D, etc...) +🎼 Manage your server queues with simple functions (add songs, skip the current song, pause the music, resume it, etc...)! +🌐 Multi-servers support + +## Getting Started + +Here is the code you will need to get started with discord-player. Then, you will be able to use `client.player` everywhere in your code! ```js const Discord = require("discord.js"), @@ -33,8 +42,8 @@ settings = { }; const { Player } = require("discord-player"); -// Create a new Player (Youtube API key is your Youtube Data v3 key) -const player = new Player(client, "YOUTUBE API KEY"); +// Create a new Player (you don't need any API Key) +const player = new Player(client); // To easily access the player client.player = player; @@ -42,84 +51,7 @@ client.on("ready", () => { console.log("I'm ready !"); }); -client.login(settings.token); -``` - -You can pass a third parameter when instantiating the class Player: the **options** object: -**options.leaveOnEnd**: whether the bot should leave the voice channel when there is no more song in the queue. -**options.leaveOnStop**: whether the bot should leave the voice channel when the `stop()` function is used. -**options.leaveOnEmpty**: whether the bot should leave the voice channel if there is no more member in it. - -### Features Overview - -You need to **init the guild queue using the play() function**, then you are able to manage the queue using the following functions: - -```js -// Play a song in the voice channel and init the guild queue -client.player.play(voiceChannel, songName); - -// Add a song to the queue -client.player.addToQueue(guildID, songName); -// Clear the queue -client.player.clearQueue(guildID); -// Get the queue -client.player.getQueue(guildID); -// Skip the current song -client.player.skip(guildID); -// Remove a song from the queue using the index number -client.player.remove(guildID, song); - - -// Pause -client.player.pause(guildID); -// Resume -client.player.resume(guildID); -// Stop -client.player.stop(guildID); - -// Check if music is playing in a guild -client.player.isPlaying(guildID); -// Get the currently playing song -client.player.nowPlaying(guildID); - - -// Current song will be repeated indefinitely -client.player.setRepeatMode(guildID, true); -// Current song will no longer be repeated indefinitely -client.player.setRepeatMode(guildID, false); -``` - -### Event messages - -```js -client.player.getQueue(guildID) -.on('end', () => { - message.channel.send('There is no more music in the queue!'); -}) -.on('songChanged', (oldSong, newSong) => { - message.channel.send(`Now playing ${newSong.name}...`); -}) -.on('channelEmpty', () => { - message.channel.send('Stop playing, there is no more member in the voice channel...'); -}); -``` - -## Examples - -### Play - -To play a song, use the `client.player.play()` function. - -**Usage:** - -```js -client.player.play(voiceChannel, songName, requestedBy); -``` - -**Example**: - -```js -client.on('message', async (message) => { +client.on("message", async (message) => { const args = message.content.slice(settings.prefix.length).trim().split(/ +/g); const command = args.shift().toLowerCase(); @@ -127,389 +59,65 @@ client.on('message', async (message) => { // !play Despacito // will play "Despacito" in the member voice channel - if(command === 'play'){ - let song = await client.player.play(message.member.voice.channel, args[0], message.member.user.tag); - message.channel.send(`Currently playing ${song.name}! - Requested by ${song.requestedBy}`); - } - -``` - -### Pause - -To pause the current song, use the `client.player.pause()` function. - -**Usage:** - -```js -client.player.pause(guildID); -``` - -**Example**: - -```js -client.on('message', async (message) => { - - const args = message.content.slice(settings.prefix.length).trim().split(/ +/g); - const command = args.shift().toLowerCase(); - - if(command === 'pause'){ - let song = await client.player.pause(message.guild.id); - message.channel.send(`${song.name} paused!`); + if(command === "play"){ + let track = await client.player.play(message.member.voice.channel, args[0], message.member.user.tag); + message.channel.send(`Currently playing ${track.name}! - Requested by ${track.requestedBy}`); } }); + +client.login(settings.token); ``` -### Resume +## [Documentation](https://discord-player.js.org) -To resume the current song, use the `client.player.resume()` function. +You will find many examples in the documentation to understand how the package works! -**Usage:** +### Methods overview + +You need to **init the guild queue using the play() function**, then you are able to manage the queue and the music using the following functions. Click on a function name to get an example code and explanations. + +#### Queue initialization + +* [play(voiceChannel, track, requestedBy)](https://discord-player.js.org/Player.html#play) - play a track in a server + +#### Queue management + +* [isPlaying(guildID)](https://discord-player.js.org/Player.html#isPlaying) - check if there is a queue for a specific server + +#### Manage tracks in your queue + +* [getQueue(guildID)](https://discord-player.js.org/Player.html#getQueue) - get the server queue +* [addToQueue(guildID, track, requestedBy)](https://discord-player.js.org/Player.html#addToQueue) - add a track to the server queue +* [clearQueue(guildID)](https://discord-player.js.org/Player.html#clearQueue) - clear the server queue +* [remove(guildID, track)](https://discord-player.js.org/Player.html#remove) - remove a track from the server queue +* [nowPlaying(guildID)](https://discord-player.js.org/Player.html#nowPlaying) - get the current track + +#### Manage music stream + +* [skip(guildID)](https://discord-player.js.org/Player.html#skip) - skip the current track +* [pause(guildID)](https://discord-player.js.org/Player.html#pause) - pause the current track +* [resume(guildID)](https://discord-player.js.org/Player.html#resume) - resume the current track +* [stop(guildID)](https://discord-player.js.org/Player.html#stop) - stop the current track +* [setFilters(data)](https://discord-player.js.org/Player.html#setFilters) - update filters (bassboost for example) +* [setRepeatMode(boolean)](https://discord-player.js.org/Player.html#setRepeatMode) - enable or disable repeat mode for the server + +### Event messages ```js -client.player.resume(guildID); -``` - -**Example**: - -```js -client.on('message', async (message) => { - - const args = message.content.slice(settings.prefix.length).trim().split(/ +/g); - const command = args.shift().toLowerCase(); - - if(command === 'resume'){ - let song = await client.player.resume(message.guild.id); - message.channel.send(`${song.name} resumed!`); - } - -}); -``` - -### Stop - -To stop the music, use the `client.player.stop()` function. - -**Usage:** - -```js -client.player.stop(guildID); -``` - -**Example**: - -```js -client.on('message', (message) => { - - const args = message.content.slice(settings.prefix.length).trim().split(/ +/g); - const command = args.shift().toLowerCase(); - - if(command === 'stop'){ - client.player.stop(message.guild.id); - message.channel.send('Music stopped!'); - } - -}); -``` - -### SetVolume - -To update the volume, use the `client.player.setVolume()` function. - -**Usage:** - -```js -client.player.setVolume(guildID, percent); -``` - -**Example**: - -```js -client.on('message', (message) => { - - const args = message.content.slice(settings.prefix.length).trim().split(/ +/g); - const command = args.shift().toLowerCase(); - - if(command === 'setvolume'){ - client.player.setVolume(message.guild.id, parseInt(args[0])); - message.channel.send(`Volume set to ${args[0]} !`); - } - -}); -``` - -### AddToQueue - -To add a song to the queue, use the `client.player.addToQueue()` function. - -**Usage:** - -```js -client.player.addToQueue(guildID, songName); -``` - -**Example:** - -In this example, you will see how to add a song to the queue if one is already playing. - -```js -client.on('message', async (message) => { - - const args = message.content.slice(settings.prefix.length).trim().split(/ +/g); - const command = args.shift().toLowerCase(); - - if(command === 'play'){ - let aSongIsAlreadyPlaying = client.player.isPlaying(message.guild.id); - // If there's already a song playing - if(aSongIsAlreadyPlaying){ - // Add the song to the queue - let song = await client.player.addToQueue(message.guild.id, args[0]); - message.channel.send(`${song.name} added to queue!`); - } else { - // Else, play the song - let song = await client.player.play(message.member.voice.channel, args[0]); - message.channel.send(`Currently playing ${song.name}!`); - } - } - -}); -``` - -### ClearQueue - -To clear the queue, use the `client.player.clearQueue()` function. - -**Usage:** - -```js -client.player.clearQueue(guildID); -``` - -**Example:** - -```js -client.on('message', (message) => { - - const args = message.content.slice(settings.prefix.length).trim().split(/ +/g); - const command = args.shift().toLowerCase(); - - if(command === 'clear-queue'){ - client.player.clearQueue(message.guild.id); - message.channel.send('Queue cleared!'); - } - -}); -``` - -### GetQueue - -To get the server queue, use the `client.player.getQueue()` function. - -**Usage:** - -```js -client.player.getQueue(guildID); -``` - -**Example:** - -```js -client.on('message', (message) => { - - const args = message.content.slice(settings.prefix.length).trim().split(/ +/g); - const command = args.shift().toLowerCase(); - - if(command === 'queue'){ - let queue = await client.player.getQueue(message.guild.id); - message.channel.send('Server queue:\n'+(queue.songs.map((song, i) => { - return `${i === 0 ? 'Current' : `#${i+1}`} - ${song.name} | ${song.author}` - }).join('\n'))); - } - - /** - * Output: - * - * Server queue: - * Current - Despacito | Luis Fonsi - * #2 - Memories | Maroon 5 - * #3 - Dance Monkey | Tones And I - * #4 - Circles | Post Malone - */ - -}); -``` - -### Skip - -To skip the current song, use the `client.player.skip()` function. - -**Usage:** - -```js -client.player.skip(guildID); -``` - -**Example**: - -```js -client.on('message', async (message) => { - - const args = message.content.slice(settings.prefix.length).trim().split(/ +/g); - const command = args.shift().toLowerCase(); - - if(command === 'skip'){ - let song = await client.player.skip(message.guild.id); - message.channel.send(`${song.name} skipped!`); - } - -}); -``` - -### Now Playing - -To get the currently playing song, use the `client.player.nowPlaying()` function. - -**Usage:** - -```js -client.player.nowPlaying(guildID); -``` - -**Example**: - -```js -client.on('message', async (message) => { - - const args = message.content.slice(settings.prefix.length).trim().split(/ +/g); - const command = args.shift().toLowerCase(); - - if(command === 'now-playing'){ - let song = await client.player.nowPlaying(message.guild.id); - message.channel.send(`Currently playing ${song.name}...`); - } - -}); -``` - -### Repeat - -To repeat the current song, use the `client.player.setRepeatMode()` function. - -**Usage:** - -```js -client.player.setRepeatMode(guildID, boolean); -``` - -**Example**: - -```js -client.on('message', async (message) => { - - const args = message.content.slice(settings.prefix.length).trim().split(/ +/g); - const command = args.shift().toLowerCase(); - - if(command === 'enable-repeat'){ - // Enable repeat mode - client.player.setRepeatMode(message.guild.id, true); - // Get the current song - let song = await client.player.nowPlaying(message.guild.id); - message.channel.send(`${song.name} will be repeated indefinitely!`); - } - - if(command === 'disable-repeat'){ - // Disable repeat mode - client.player.setRepeatMode(message.guild.id, false); - // Get the current song - let song = await client.player.nowPlaying(message.guild.id); - message.channel.send(`${song.name} will no longer be repeated indefinitely!`); - } - -}); -``` - -### Remove - -To remove a song from the queue, use the `client.player.remove()` function. - -**Usage:** - -```js -client.player.remove(guildID, song); -``` - -**Example:** - -```js -client.on('message', async (message) => { - - const args = message.content.slice(settings.prefix.length).trim().split(/ +/g); - const command = args.shift().toLowerCase(); - - if(command === 'remove'){ - // Removes a song from the queue - client.player.remove(message.guild.id, args[0]).then(() => { - message.channel.send('Removed song!'); - }); - } -}); -``` - -## Info Messages - -You can send a message when the queue ends or when the song changes: - -```js -client.on('message', (message) => { - - const args = message.content.slice(settings.prefix.length).trim().split(/ +/g); - const command = args.shift().toLowerCase(); - - if(command === 'play'){ - let song = await client.player.play(message.member.voice.channel, args[0]); - song.queue.on('end', () => { - message.channel.send('The queue is empty, please add new songs!'); - }); - song.queue.on('songChanged', (oldSong, newSong, skipped, repeatMode) => { - if(repeatMode){ - message.channel.send(`Playing ${newSong} again...`); - } else { - message.channel.send(`Now playing ${newSong}...`); - } - }); - } - -``` - -## Handle errors - -There are 2 main errors that you can handle like this: - -```js -client.on('message', (message) => { - - const args = message.content.slice(settings.prefix.length).trim().split(/ +/g); - const command = args.shift().toLowerCase(); - - // Error 1: - // Song not found - if(command === 'play'){ - client.player.play(message.member.voice.channel, args[0]).then((song) => { - message.channel.send(`Currently playing ${song.name}!`); - }).catch(() => { - message.channel.send(`No song found for ${args[0]}`); - }); - } - - // Error 2: - // Not playing - if(command === 'queue'){ - let playing = client.player.isPlaying(message.guild.id); - if(!playing) return message.channel.send(':x: No songs currently playing!'); - // you are sure it works: - client.player.getQueue(message.guild.id); - } - +// Play the music +await client.player.play(message.member.voice.channel, "Despacito") + +// Then add some messages that will be sent when the events will be triggered +client.player.getQueue(guildID) +.on('end', () => { + message.channel.send('There is no more music in the queue!'); +}) +.on('trackChanged', (oldTrack, newTrack) => { + message.channel.send(`Now playing ${newTrack.name}...`); +}) +.on('channelEmpty', () => { + message.channel.send('Stop playing, there is no more member in the voice channel...'); }); ``` diff --git a/package.json b/package.json index 5f7085c..e8e8e04 100644 --- a/package.json +++ b/package.json @@ -28,13 +28,22 @@ }, "homepage": "https://github.com/Androz2091/discord-player#readme", "dependencies": { - "ffmpeg-static": "^4.1.1", + "discord-ytdl-core": "^4.0.2", "merge-options": "^2.0.0", - "simple-youtube-api": "^5.2.1", - "ytdl-core": "^3.0.0" + "node-fetch": "^2.6.0", + "spotify-url-info": "^1.3.1", + "ytpl": "^0.1.22", + "ytsr": "^0.1.15" }, "devDependencies": { - "discord.js": "discordjs/discord.js", + "@discordjs/opus": "^0.3.2", + "discord.js": "^12.2.0", + "eslint": "^7.1.0", + "eslint-config-standard": "^14.1.1", + "eslint-plugin-import": "^2.20.2", + "eslint-plugin-node": "^11.1.0", + "eslint-plugin-promise": "^4.2.1", + "eslint-plugin-standard": "^4.0.1", "jsdoc": "^3.6.3", "minami": "Androz2091/minami" } diff --git a/src/Player.js b/src/Player.js index 8ec0679..c1610d1 100644 --- a/src/Player.js +++ b/src/Player.js @@ -1,406 +1,886 @@ -const ytdl = require('ytdl-core'); -const SYA = require('simple-youtube-api'); -const mergeOptions = require('merge-options'); - -const { VoiceChannel, version } = require("discord.js"); -if(version.split('.')[0] !== '12') throw new Error("Only the master branch of discord.js library is supported for now. Install it using 'npm install discordjs/discord.js'."); -const Queue = require('./Queue'); -const Util = require('./Util'); -const Song = require('./Song'); +const ytdl = require('discord-ytdl-core') +const Discord = require('discord.js') +const ytsr = require('ytsr') +const ytpl = require('ytpl') +const spotify = require('spotify-url-info') +const Queue = require('./Queue') +const Track = require('./Track') /** - * Player options. - * @typedef {PlayerOptions} - * - * @property {Boolean} leaveOnEnd Whether the bot should leave the current voice channel when the queue ends. - * @property {Boolean} leaveOnStop Whether the bot should leave the current voice channel when the stop() function is used. - * @property {Boolean} leaveOnEmpty Whether the bot should leave the voice channel if there is no more member in it. - */ -const PlayerOptions = { + * @typedef Filters + * @property {boolean} [bassboost=false] Whether the bassboost filter is enabled. + * @property {boolean} [8D=false] Whether the 8D filter is enabled. + * @property {boolean} [vaporwave=false] Whether the vaporwave filter is enabled. + * @property {boolean} [nightcore=false] Whether the nightcore filter is enabled. + * @property {boolean} [phaser=false] Whether the phaser filter is enabled. + * @property {boolean} [tremolo=false] Whether the tremolo filter is enabled. + * @property {boolean} [vibrato=false] Whether the vibrato filter is enabled. + * @property {boolean} [reverse=false] Whether the reverse filter is enabled. + * @property {boolean} [treble=false] Whether the treble filter is enabled. + * @property {boolean} [normalizer=false] Whether the normalizer filter is enabled. + * @property {boolean} [surrounding=false] Whether the surrounding filter is enabled. + * @property {boolean} [pulsator=false] Whether the pulsator filter is enabled. + * @property {boolean} [subboost=false] Whether the subboost filter is enabled. + */ + +const filters = { + bassboost: 'bass=g=20,dynaudnorm=f=200', + '8D': 'apulsator=hz=0.128', + vaporwave: 'asetrate=44100*0.8,aresample=44100,atempo=1.1', + nightcore: 'asetrate=44100*1.25', + phaser: 'aphaser=in_gain=0.4', + tremolo: 'tremolo', + vibrato: 'vibrato=f=6.5', + reverse: 'areverse', + treble: 'treble=g=5', + normalizer: 'dynaudnorm=f=150', + surrounding: 'surround', + pulsator: 'apulsator=hz=1', + subboost: 'asubboost' +} + +/** + * @typedef PlayerOptions + * @property {boolean} [leaveOnEnd=true] Whether the bot should leave the current voice channel when the queue ends. + * @property {boolean} [leaveOnStop=true] Whether the bot should leave the current voice channel when the stop() function is used. + * @property {boolean} [leaveOnEmpty=true] Whether the bot should leave the voice channel if there is no more member in it. + */ + +/** + * Default options for the player + * @ignore + * @type {PlayerOptions} + */ +const defaultPlayerOptions = { leaveOnEnd: true, leaveOnStop: true, leaveOnEmpty: true -}; +} class Player { - /** - * @param {Client} client Your Discord Client instance. - * @param {string} youtubeToken Your Youtube Data v3 API key. - * @param {PlayerOptions} options The PlayerOptions object. + * @param {Discord.Client} client Discord.js client + * @param {PlayerOptions} options Player options */ - constructor(client, youtubeToken, options = {}){ - if(!client) throw new SyntaxError('Invalid Discord client'); - if(!youtubeToken) throw new SyntaxError('Invalid Token: Token must be a String'); + constructor (client, options = {}) { + if (!client) throw new SyntaxError('Invalid Discord client') + /** - * Your Discord Client instance. - * @type {Client} + * Discord.js client instance + * @type {Discord.Client} */ - this.client = client; + this.client = client /** - * Your Youtube Data v3 API key. - * @type {string} - */ - this.youtubeToken = youtubeToken; - /** - * The Simple Youtube API Client. - * @type {Youtube} - */ - this.SYA = new SYA(this.youtubeToken); - /** - * The guilds data. + * Player queues * @type {Queue[]} */ - this.queues = []; + this.queues = [] /** - * Player options. + * Player options * @type {PlayerOptions} */ - this.options = mergeOptions(PlayerOptions, options); + this.options = defaultPlayerOptions + for (const prop in options) { + this.options[prop] = options[prop] + } + /** + * Default filters for the queues created with this player. + * @type {Filters} + */ + this.filters = filters // Listener to check if the channel is empty - client.on('voiceStateUpdate', (oldState, newState) => { - if(!this.options.leaveOnEmpty) return; - // If the member leaves a voice channel - if(!oldState.channelID || newState.channelID) return; - // Search for a queue for this channel - let queue = this.queues.find((g) => g.connection.channel.id === oldState.channelID); - if(queue){ - // If the channel is not empty - if(queue.connection.channel.members.size > 1) return; - // Disconnect from the voice channel - queue.connection.channel.leave(); - // Delete the queue - this.queues = this.queues.filter((g) => g.guildID !== queue.guildID); - // Emit end event - queue.emit('channelEmpty'); - } - }); + client.on('voiceStateUpdate', (oldState, newState) => this._handleVoiceStateUpdate(oldState, newState)) } /** - * Whether a guild is currently playing songs - * @param {string} guildID The guild ID to check - * @returns {Boolean} Whether the guild is currently playing songs + * Set the filters enabled for the guild. [Full list of the filters](https://discord-player.js.org/global.html#Filters) + * @param {Discord.Snowflake} guildID + * @param {Filters} newFilters + * + * @example + * client.on('message', async (message) => { + * + * const args = message.content.slice(settings.prefix.length).trim().split(/ +/g); + * const command = args.shift().toLowerCase(); + * + * if(command === 'bassboost'){ + * const bassboostEnabled = client.player.getQueue(message.guild.id); + * if(!bassboostEnabled){ + * client.player.updateFilters(message.guild.id, { + * bassboost: true + * }); + * message.channel.send("Bassboost effect has been enabled!"); + * } else { + * client.player.updateFilters(message.guild.id, { + * bassboost: false + * }); + * message.channel.send("Bassboost effect has been disabled!"); + * } + * } + * + * }); */ - isPlaying(guildID) { - return this.queues.some((g) => g.guildID === guildID); + setFilters (guildID, newFilters) { + return new Promise((resolve, reject) => { + // Gets guild queue + const queue = this.queues.find((g) => g.guildID === guildID) + if (!queue) return reject(new Error('Not playing')) + Object.keys(newFilters).forEach((filterName) => { + queue.filters[filterName] = newFilters[filterName] + }) + this._playYTDLStream(queue, true, false) + }) } /** - * Plays a song in a voice channel. - * @param {voiceChannel} voiceChannel The voice channel in which the song will be played. - * @param {string} songName The name of the song to play. - * @param {User} requestedBy The user who requested the song. - * @returns {Promise} + * Searchs tracks on YouTube + * @param {string} query The query + * @returns {Promise} + * + * @example + * client.on('message', async (message) => { + * + * const args = message.content.slice(settings.prefix.length).trim().split(/ +/g); + * const command = args.shift().toLowerCase(); + * + * if(command === 'play'){ + * // Search for tracks + * let tracks = await client.player.searchTracks(args[0]); + * // Sends an embed with the 10 first songs + * if(tracks.length > 10) tracks = tracks.substr(0, 10); + * const embed = new Discord.MessageEmbed() + * .setDescription(tracks.map((t, i) => `**${i+1} -** ${t.name}`).join("\n")) + * .setFooter("Send the number of the track you want to play!"); + * message.channel.send(embed); + * // Wait for user answer + * await message.channel.awaitMessages((m) => m.content > 0 && m.content < 10, { max: 1, time: 20000, errors: ["time"] }).then(async (answers) => { + * let index = parseInt(answers.first().content, 10); + * track = track[index-1]; + * // Then play the song + * client.player.play(message.member.voice.channel, track); + * }); + * } + * + * }); */ - play(voiceChannel, songName, requestedBy) { - this.queues = this.queues.filter((g) => g.guildID !== voiceChannel.id); + searchTracks (query) { return new Promise(async (resolve, reject) => { - if(!voiceChannel || typeof voiceChannel !== "object") return reject("voiceChannel must be type of VoiceChannel. value="+voiceChannel); - if(typeof songName !== "string") return reject("songName must be type of string. value="+songName); - // Searches the song - let video = await Util.getFirstYoutubeResult(songName, this.SYA); - if(!video) return reject('Song not found'); - // Joins the voice channel - let connection = await voiceChannel.join(); - // Creates a new guild with data - let queue = new Queue(voiceChannel.guild.id); - queue.connection = connection; - let song = new Song(video, queue, requestedBy); - queue.songs.push(song); + if (ytpl.validateURL(query)) { + const playlistID = await ytpl.getPlaylistID(query).catch(() => {}) + if (playlistID) { + const playlist = await ytpl(playlistID).catch(() => {}) + if (playlist) { + return resolve(playlist.items.map((i) => new Track({ + title: i.title, + duration: i.duration, + thumbnail: i.thumbnail, + author: i.author, + link: i.url + }, null, null))) + } + } + } + const matchSpotifyURL = query.match(/https?:\/\/(?:embed\.|open\.)(?:spotify\.com\/)(?:track\/|\?uri=spotify:track:)((\w|-){22})/) + if (matchSpotifyURL) { + const spotifyData = await spotify.getPreview(query).catch(e => resolve([])) + query = `${spotifyData.artist} - ${spotifyData.track}` + } + // eslint-disable-next-line no-useless-escape + const matchYoutubeURL = query.match(/(?:youtube\.com\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?\/\s]{11})/) + if (matchYoutubeURL) { + query = matchYoutubeURL[1] + } + ytsr(query, (err, results) => { + if (results.items.length < 1) return resolve([]) + if (err) return resolve([]) + const resultsVideo = results.items.filter((i) => i.type === 'video') + resolve([new Track(resultsVideo[0], null, null)]) + }) + }) + } + + /** + * Whether a guild is currently playing something + * @param {Discord.Snowflake} guildID The guild ID to check + * @returns {boolean} Whether the guild is currently playing tracks + */ + isPlaying (guildID) { + return this.queues.some((g) => g.guildID === guildID) + } + + /** + * Play a track in a voice channel + * @param {Discord.VoiceChannel} voiceChannel The voice channel in which the track will be played + * @param {Track|string} track The name of the track to play + * @param {Discord.User?} user The user who requested the track + * @returns {any} The played content + * + * @example + * client.on('message', async (message) => { + * + * const args = message.content.slice(settings.prefix.length).trim().split(/ +/g); + * const command = args.shift().toLowerCase(); + * + * // !play Despacito + * // will play "Despacito" in the member voice channel + * + * if(command === 'play'){ + * const result = await client.player.play(message.member.voice.channel, args.join(" ")); + * if(result.type === 'playlist'){ + * message.channel.send(`${result.tracks.length} songs added to the queue ${emotes.music}\nCurrently playing **${result.tracks[0].name}**!`); + * } else { + * message.channel.send(`Currently playing ${result.name} ${emotes.music}`); + * } + * } + * + * }); + */ + play (voiceChannel, track, user) { + this.queues = this.queues.filter((g) => g.guildID !== voiceChannel.id) + return new Promise(async (resolve, reject) => { + if (!voiceChannel || typeof voiceChannel !== 'object') { + return reject(new Error(`voiceChannel must be type of VoiceChannel. value=${voiceChannel}`)) + } + const connection = voiceChannel.client.voice.connections.find((c) => c.channel.id === voiceChannel.id) || await voiceChannel.join() + // Create a new guild with data + const queue = new Queue(voiceChannel.guild.id) + queue.voiceConnection = connection + queue.filters = {} + Object.keys(this.filters).forEach((f) => { + queue.filters[f] = false + }) + let result = null + if (typeof track === 'object') { + track.requestedBy = user + result = track + // Add the track to the queue + queue.tracks.push(track) + } else if (typeof track === 'string') { + const results = await this.searchTracks(track).catch(() => { + return reject(new Error('Not found')) + }) + if (!results) return + if (results.length > 1) { + result = { + type: 'playlist', + tracks: results + } + } else if (results[0]) { + result = results[0] + } else { + return reject(new Error('Not found')) + } + results.forEach((i) => { + i.requestedBy = user + queue.tracks.push(i) + }) + } // Add the queue to the list - this.queues.push(queue); - // Plays the song - this._playSong(queue.guildID, true); - // Resolves the song. - resolve(song); - }); + this.queues.push(queue) + // Play the track + this._playTrack(queue.guildID, true) + // Resolve the track + resolve(result) + }) } /** - * Pauses the current song. - * @param {string} guildID - * @returns {Promise} + * Pause the current track + * @param {Discord.Snowflake} guildID The ID of the guild where the current track should be paused + * @returns {Promise} The paused track + * + * @example + * client.on('message', async (message) => { + * + * const args = message.content.slice(settings.prefix.length).trim().split(/ +/g); + * const command = args.shift().toLowerCase(); + * + * if(command === 'pause'){ + * let track = await client.player.pause(message.guild.id); + * message.channel.send(`${track.name} paused!`); + * } + * + * }); */ - pause(guildID){ - return new Promise(async(resolve, reject) => { + pause (guildID) { + return new Promise((resolve, reject) => { // Gets guild queue - let queue = this.queues.find((g) => g.guildID === guildID); - if(!queue) return reject('Not playing'); + const queue = this.queues.find((g) => g.guildID === guildID) + if (!queue) return reject(new Error('Not playing')) // Pauses the dispatcher - queue.dispatcher.pause(); - queue.playing = false; + queue.voiceConnection.dispatcher.pause() + queue.paused = true // Resolves the guild queue - resolve(queue.songs[0]); - }); + resolve(queue.playing) + }) } /** - * Resumes the current song. - * @param {string} guildID - * @returns {Promise} + * Resume the current track + * @param {Discord.Snowflake} guildID The ID of the guild where the current track should be resumed + * @returns {Promise} The resumed track + * + * @example + * client.on('message', async (message) => { + * + * const args = message.content.slice(settings.prefix.length).trim().split(/ +/g); + * const command = args.shift().toLowerCase(); + * + * if(command === 'resume'){ + * let track = await client.player.resume(message.guild.id); + * message.channel.send(`${track.name} resumed!`); + * } + * + * }); */ - resume(guildID){ - return new Promise(async(resolve, reject) => { - // Gets guild queue - let queue = this.queues.find((g) => g.guildID === guildID); - if(!queue) return reject('Not playing'); - // Pauses the dispatcher - queue.dispatcher.resume(); - queue.playing = true; - // Resolves the guild queue - resolve(queue.songs[0]); - }); + resume (guildID) { + return new Promise((resolve, reject) => { + // Get guild queue + const queue = this.queues.find((g) => g.guildID === guildID) + if (!queue) return reject(new Error('Not playing')) + // Pause the dispatcher + queue.voiceConnection.dispatcher.resume() + queue.paused = false + // Resolve the guild queue + resolve(queue.playing) + }) } /** * Stops playing music. - * @param {string} guildID + * @param {Discord.Snowflake} guildID The ID of the guild where the music should be stopped * @returns {Promise} + * + * @example + * client.on('message', (message) => { + * + * const args = message.content.slice(settings.prefix.length).trim().split(/ +/g); + * const command = args.shift().toLowerCase(); + * + * if(command === 'stop'){ + * client.player.stop(message.guild.id); + * message.channel.send('Music stopped!'); + * } + * + * }); */ - stop(guildID){ - return new Promise(async(resolve, reject) => { - // Gets guild queue - let queue = this.queues.find((g) => g.guildID === guildID); - if(!queue) return reject('Not playing'); - // Stops the dispatcher - queue.stopped = true; - queue.songs = []; - queue.dispatcher.end(); - // Resolves - resolve(); - }); + stop (guildID) { + return new Promise((resolve, reject) => { + // Get guild queue + const queue = this.queues.find((g) => g.guildID === guildID) + if (!queue) return reject(new Error('Not playing')) + // Stop the dispatcher + queue.stopped = true + queue.tracks = [] + queue.voiceConnection.dispatcher.end() + // Resolve + resolve() + }) } /** - * Updates the volume. - * @param {string} guildID - * @param {number} percent + * Update the volume + * @param {Discord.Snowflake} guildID The ID of the guild where the music should be modified + * @param {number} percent The new volume (0-100) * @returns {Promise} + * + * @example + * client.on('message', (message) => { + * + * const args = message.content.slice(settings.prefix.length).trim().split(/ +/g); + * const command = args.shift().toLowerCase(); + * + * if(command === 'setvolume'){ + * client.player.setVolume(message.guild.id, parseInt(args[0])); + * message.channel.send(`Volume set to ${args[0]} !`); + * } + * + * }); */ - setVolume(guildID, percent) { - return new Promise(async(resolve, reject) => { - // Gets guild queue - let queue = this.queues.find((g) => g.guildID === guildID); - if(!queue) return reject('Not playing'); + setVolume (guildID, percent) { + return new Promise((resolve, reject) => { + // Get guild queue + const queue = this.queues.find((g) => g.guildID === guildID) + if (!queue) return reject(new Error('Not playing')) // Updates volume - queue.dispatcher.setVolumeLogarithmic(percent / 200); + queue.volume = percent + queue.voiceConnection.dispatcher.setVolumeLogarithmic(queue.calculatedVolume / 200) // Resolves guild queue - resolve(queue); - }); + resolve() + }) } /** - * Gets the guild queue. - * @param {string} guildID + * Get a guild queue + * @param {Discord.Snowflake} guildID * @returns {?Queue} + * + * @example + * client.on('message', (message) => { + * + * const args = message.content.slice(settings.prefix.length).trim().split(/ +/g); + * const command = args.shift().toLowerCase(); + * + * if(command === 'queue'){ + * let queue = await client.player.getQueue(message.guild.id); + * message.channel.send('Server queue:\n'+(queue.tracks.map((track, i) => { + * return `${i === 0 ? 'Current' : `#${i+1}`} - ${track.name} | ${track.author}`; + * }).join('\n'))); + * } + * + * // Output: + * + * // Server queue: + * // Current - Despacito | Luis Fonsi + * // #2 - Memories | Maroon 5 + * // #3 - Dance Monkey | Tones And I + * // #4 - Circles | Post Malone + * }); */ - getQueue(guildID) { + getQueue (guildID) { // Gets guild queue - let queue = this.queues.find((g) => g.guildID === guildID); - return queue; + const queue = this.queues.find((g) => g.guildID === guildID) + return queue } /** - * Adds a song to the guild queue. - * @param {string} guildID - * @param {string} songName The name of the song to add to the queue. - * @param {User} requestedBy The user who requested the song. - * @returns {Promise} + * Add a track to the guild queue + * @param {Discord.Snowflake} guildID The ID of the guild where the track should be added + * @param {Track|string} trackName The name of the track to add to the queue + * @param {Discord.User?} user The user who requested the track + * @returns {any} The content added to the queue + * + * @example + * client.on('message', async (message) => { + * + * const args = message.content.slice(settings.prefix.length).trim().split(/ +/g); + * const command = args.shift().toLowerCase(); + * + * if(command === 'play'){ + * let trackPlaying = client.player.isPlaying(message.guild.id); + * // If there's already a track being played + * if(trackPlaying){ + * const result = await client.player.addToQueue(message.guild.id, args.join(" ")); + * if(result.type === 'playlist'){ + * message.channel.send(`${result.tracks.length} songs added to the queue ${emotes.music}`); + * } else { + * message.channel.send(`${result.name} added to the queue ${emotes.music}`); + * } + * } else { + * // Else, play the track + * const result = await client.player.addToQueue(message.member.voice.channel, args[0]); + * if(result.type === 'playlist'){ + * message.channel.send(`${result.tracks.length} songs added to the queue ${emotes.music}\nCurrently playing **${result.tracks[0].name}**!`); + * } else { + * message.channel.send(`Currently playing ${result.name} ${emotes.music}`); + * } + * } + * } + * + * }); */ - addToQueue(guildID, songName, requestedBy){ - return new Promise(async(resolve, reject) => { - // Gets guild queue - let queue = this.queues.find((g) => g.guildID === guildID); - if(!queue) return reject('Not playing'); - // Searches the song - let video = await Util.getFirstYoutubeResult(songName, this.SYA).catch(() => {}); - if(!video) return reject('Song not found'); - let song = new Song(video, queue, requestedBy); - // Updates queue - queue.songs.push(song); - // Resolves the song - resolve(song); - }); + addToQueue (guildID, track, user) { + return new Promise(async (resolve, reject) => { + // Get guild queue + const queue = this.queues.find((g) => g.guildID === guildID) + if (!queue) return reject(new Error('Not playing')) + // Search the track + let result = null + if (typeof track === 'object') { + track.requestedBy = user + result = track + // Add the track to the queue + queue.tracks.push(track) + } else if (typeof track === 'string') { + const results = await this.searchTracks(track).catch(() => { + return reject(new Error('Not found')) + }) + if (!results) return + if (results.length > 1) { + result = { + type: 'playlist', + tracks: results + } + } else if (results[0]) { + result = results[0] + } else { + return reject(new Error('Not found')) + } + results.forEach((i) => { + i.requestedBy = user + queue.tracks.push(i) + }) + } + // Resolve the result + resolve(result) + }) } /** - * Sets the queue for a guild. - * @param {string} guildID - * @param {Array} songs The songs list - * @returns {Promise} + * Clear the guild queue, except the current track + * @param {Discord.Snowflake} guildID The ID of the guild where the queue should be cleared + * @returns {Promise} The updated queue + * + * @example + * client.on('message', (message) => { + * + * const args = message.content.slice(settings.prefix.length).trim().split(/ +/g); + * const command = args.shift().toLowerCase(); + * + * if(command === 'clear-queue'){ + * client.player.clearQueue(message.guild.id); + * message.channel.send('Queue cleared!'); + * } + * + * }); */ - setQueue(guildID, songs){ - return new Promise(async(resolve, reject) => { - // Gets guild queue - let queue = this.queues.find((g) => g.guildID === guildID); - if(!queue) return reject('Not playing'); - // Updates queue - queue.songs = songs; - // Resolves the queue - resolve(queue); - }); + clearQueue (guildID) { + return new Promise((resolve, reject) => { + // Get guild queue + const queue = this.queues.find((g) => g.guildID === guildID) + if (!queue) return reject(new Error('Not playing')) + // Clear queue + const currentlyPlaying = queue.tracks.shift() + queue.tracks = [currentlyPlaying] + // Resolve guild queue + resolve(queue) + }) } /** - * Clears the guild queue, but not the current song. - * @param {string} guildID - * @returns {Promise} + * Skip a track + * @param {Discord.Snowflake} guildID The ID of the guild where the track should be skipped + * @returns {Promise} + * + * @example + * client.on('message', async (message) => { + * + * const args = message.content.slice(settings.prefix.length).trim().split(/ +/g); + * const command = args.shift().toLowerCase(); + * + * if(command === 'skip'){ + * let track = await client.player.skip(message.guild.id); + * message.channel.send(`${track.name} skipped!`); + * } + * + * }); */ - clearQueue(guildID){ - return new Promise(async(resolve, reject) => { - // Gets guild queue - let queue = this.queues.find((g) => g.guildID === guildID); - if(!queue) return reject('Not playing'); - // Clears queue - let currentlyPlaying = queue.songs.shift(); - queue.songs = [ currentlyPlaying ]; - // Resolves guild queue - resolve(queue); - }); + skip (guildID) { + return new Promise((resolve, reject) => { + // Get guild queue + const queue = this.queues.find((g) => g.guildID === guildID) + if (!queue) return reject(new Error('Not playing')) + const currentTrack = queue.playing + // End the dispatcher + queue.voiceConnection.dispatcher.end() + queue.lastSkipped = true + // Resolve the current track + resolve(currentTrack) + }) } /** - * Skips a song. - * @param {string} guildID - * @returns {Promise} + * Get the currently playing track + * @param {Discord.Snowflake} guildID + * @returns {Promise} The track which is currently played + * + * @example + * client.on('message', async (message) => { + * + * const args = message.content.slice(settings.prefix.length).trim().split(/ +/g); + * const command = args.shift().toLowerCase(); + * + * if(command === 'now-playing'){ + * let track = await client.player.nowPlaying(message.guild.id); + * message.channel.send(`Currently playing ${track.name}...`); + * } + * + * }); */ - skip(guildID){ - return new Promise(async(resolve, reject) => { - // Gets guild queue - let queue = this.queues.find((g) => g.guildID === guildID); - if(!queue) return reject('Not playing'); - let currentSong = queue.songs[0]; - // Ends the dispatcher - queue.dispatcher.end(); - queue.skipped = true; - // Resolves the current song - resolve(currentSong); - }); - } - - /** - * Gets the currently playing song. - * @param {string} guildID - * @returns {Promise} - */ - nowPlaying(guildID){ - return new Promise(async(resolve, reject) => { - // Gets guild queue - let queue = this.queues.find((g) => g.guildID === guildID); - if(!queue) return reject('Not playing'); - let currentSong = queue.songs[0]; - // Resolves the current song - resolve(currentSong); - }); + nowPlaying (guildID) { + return new Promise((resolve, reject) => { + // Get guild queue + const queue = this.queues.find((g) => g.guildID === guildID) + if (!queue) return reject(new Error('Not playing')) + const currentTrack = queue.playing + // Resolve the current track + resolve(currentTrack) + }) } /** * Enable or disable the repeat mode - * @param {string} guildID + * @param {Discord.Snowflake} guildID * @param {Boolean} enabled Whether the repeat mode should be enabled * @returns {Promise} + * + * @example + * client.on('message', async (message) => { + * + * const args = message.content.slice(settings.prefix.length).trim().split(/ +/g); + * const command = args.shift().toLowerCase(); + * + * if(command === 'repeat-mode'){ + * const repeatModeEnabled = client.player.getQueue(message.guild.id).repeatMode; + * if(repeatModeEnabled){ + * // if the repeat mode is currently enabled, disable it + * client.player.setRepeatMode(message.guild.id, false); + * message.channel.send("Repeat mode disabled! The current song will no longer be played again and again..."); + * } else { + * // if the repeat mode is currently disabled, enable it + * client.player.setRepeatMode(message.guild.id, true); + * message.channel.send("Repeat mode enabled! The current song will be played again and again until you run the command again!"); + * } + * } + * + * }); */ - setRepeatMode(guildID, enabled) { - return new Promise(async(resolve, reject) => { - // Gets guild queue - let queue = this.queues.find((g) => g.guildID === guildID); - if(!queue) return reject('Not playing'); + setRepeatMode (guildID, enabled) { + return new Promise((resolve, reject) => { + // Get guild queue + const queue = this.queues.find((g) => g.guildID === guildID) + if (!queue) return reject(new Error('Not playing')) // Enable/Disable repeat mode - queue.repeatMode = enabled; + queue.repeatMode = enabled // Resolve - resolve(); - }); + resolve() + }) } /** - * Shuffles the guild queue. - * @param {string} guildID - * @returns {Promise} + * Shuffle the guild queue (except the first track) + * @param {Discord.Snowflake} guildID The ID of the guild where the queue should be shuffled + * @returns {Promise} The updated queue + * + * @example + * client.on('message', async (message) => { + * + * const args = message.content.slice(settings.prefix.length).trim().split(/ +/g); + * const command = args.shift().toLowerCase(); + * + * if(command === 'shuffle'){ + * // Shuffle the server queue + * client.player.shuffle(message.guild.id).then(() => { + * message.channel.send('Queue shuffled!'); + * }); + * } + * + * }); */ - shuffle(guildID){ - return new Promise(async(resolve, reject) => { - // Gets guild queue - let queue = this.queues.find((g) => g.guildID === guildID); - if(!queue) return reject('Not playing'); - // Shuffle the queue (except the first song) - let currentSong = queue.songs.shift(); - queue.songs = queue.songs.sort(() => Math.random() - 0.5); - queue.songs.unshift(currentSong); + shuffle (guildID) { + return new Promise((resolve, reject) => { + // Get guild queue + const queue = this.queues.find((g) => g.guildID === guildID) + if (!queue) return reject(new Error('Not playing')) + // Shuffle the queue (except the first track) + const currentTrack = queue.tracks.shift() + queue.tracks = queue.tracks.sort(() => Math.random() - 0.5) + queue.tracks.unshift(currentTrack) // Resolve - resolve(); - }); + resolve(queue) + }) } /** - * Removes a song from the queue - * @param {string} guildID - * @param {number|Song} song The index of the song to remove or the song to remove object. - * @returns {Promise} + * Remove a track from the queue + * @param {Discord.Snowflake} guildID The ID of the guild where the track should be removed + * @param {number|Track} track The index of the track to remove or the track to remove object + * @returns {Promise} + * + * @example + * client.on('message', async (message) => { + * + * const args = message.content.slice(settings.prefix.length).trim().split(/ +/g); + * const command = args.shift().toLowerCase(); + * + * if(command === 'remove'){ + * // Removes a track from the queue + * client.player.remove(message.guild.id, args[0]).then(() => { + * message.channel.send('Removed track!'); + * }); + * } + * + * }); */ - remove(guildID, song){ - return new Promise(async(resolve, reject) => { + remove (guildID, track) { + return new Promise((resolve, reject) => { // Gets guild queue - let queue = this.queues.find((g) => g.guildID === guildID); - if(!queue) return reject('Not playing'); - // Remove the song from the queue - let songFound = null; - if(typeof song === "number"){ - songFound = queue.songs[song]; - if(songFound){ - queue.songs = queue.songs.filter((s) => s !== songFound); + const queue = this.queues.find((g) => g.guildID === guildID) + if (!queue) return reject(new Error('Not playing')) + // Remove the track from the queue + let trackFound = null + if (typeof track === 'number') { + trackFound = queue.tracks[track] + if (trackFound) { + queue.tracks = queue.tracks.filter((t) => t !== trackFound) } } else { - songFound = queue.songs.find((s) => s === song); - if(songFound){ - queue.songs = queue.songs.filter((s) => s !== songFound); + trackFound = queue.tracks.find((s) => s === track) + if (trackFound) { + queue.tracks = queue.tracks.filter((s) => s !== trackFound) } } // Resolve - resolve(songFound); - }); + resolve(trackFound) + }) } /** - * Start playing songs in a guild. - * @ignore - * @param {string} guildID - * @param {Boolean} firstPlay Whether the function was called from the play() one + * Creates progress bar of the current song + * @param {Discord.Snowflake} guildID + * @returns {String} + * + * @example + * client.on('message', async (message) => { + * + * const args = message.content.slice(settings.prefix.length).trim().split(/ +/g); + * const command = args.shift().toLowerCase(); + * + * if(command === 'now-playing'){ + * // Shuffle the server queue + * client.player.nowPlaying(message.guild.id).then((song) => { + * message.channel.send('Currently playing ' + song.name + '\n\n'+ client.player.createProgressBar(message.guild.id)); + * }); + * } + * + * }); */ - async _playSong(guildID, firstPlay) { + createProgressBar (guildID) { // Gets guild queue - let queue = this.queues.find((g) => g.guildID === guildID); - // If there isn't any music in the queue - if(queue.songs.length < 2 && !firstPlay && !queue.repeatMode){ - // Leaves the voice channel - if(this.options.leaveOnEnd && !queue.stopped) queue.connection.channel.leave(); - // Remoces the guild from the guilds list - this.queues = this.queues.filter((g) => g.guildID !== guildID); - // Emits stop event - if(queue.stopped){ - if(this.options.leaveOnStop) queue.connection.channel.leave(); - return queue.emit('stop'); - } - // Emits end event - return queue.emit('end'); + const queue = this.queues.find((g) => g.guildID === guildID) + if (!queue) return + // Stream time of the dispatcher + const currentStreamTime = queue.calculatedStreamTime + // Total stream time + const totalTime = queue.playing.durationMS + // Stream progress + const index = Math.round((currentStreamTime / totalTime) * 15) + // conditions + if ((index >= 1) && (index <= 15)) { + const bar = '▬▬▬▬▬▬▬▬▬▬▬▬▬▬'.split('') + bar.splice(index, 0, '🔘') + return bar.join('') + } else { + return '🔘▬▬▬▬▬▬▬▬▬▬▬▬▬▬' } - // Emit songChanged event - if(!firstPlay) queue.emit('songChanged', (!queue.repeatMode ? queue.songs.shift() : queue.songs[0]), queue.songs[0], queue.skipped, queue.repeatMode); - queue.skipped = false; - let song = queue.songs[0]; - // Download the song - let dispatcher = queue.connection.play(ytdl(song.url, { filter: "audioonly" })); - queue.dispatcher = dispatcher; - // Set volume - dispatcher.setVolumeLogarithmic(queue.volume / 200); - // When the song ends - dispatcher.on('finish', () => { - // Play the next song - return this._playSong(guildID, false); - }); } + /** + * Handle the voice state update event + * @ignore + * @private + * @param {Discord.VoiceState} oldState + * @param {Discord.VoiceState} newState + */ + _handleVoiceStateUpdate (oldState, newState) { + if (!this.options.leaveOnEmpty) return + // If the member leaves a voice channel + if (!oldState.channelID || newState.channelID) return + // Search for a queue for this channel + const queue = this.queues.find((g) => g.voiceConnection.channel.id === oldState.channelID) + if (queue) { + // If the channel is not empty + if (queue.voiceConnection.channel.members.size > 1) return + // Disconnect from the voice channel + queue.voiceConnection.channel.leave() + // Delete the queue + this.queues = this.queues.filter((g) => g.guildID !== queue.guildID) + // Emit end event + queue.emit('channelEmpty') + } + } + + /** + * Play a stream in a channel + * @ignore + * @private + * @param {Queue} queue The queue to play + * @param {Boolean} updateFilter Whether this method is called to update some ffmpeg filters + * @returns {Promise} + */ + _playYTDLStream (queue, updateFilter) { + return new Promise((resolve) => { + const currentStreamTime = updateFilter ? queue.voiceConnection.dispatcher.streamTime / 1000 : undefined + const encoderArgsFilters = [] + Object.keys(queue.filters).forEach((filterName) => { + if (queue.filters[filterName]) { + encoderArgsFilters.push(filters[filterName]) + } + }) + let encoderArgs + if (encoderArgsFilters.length < 1) { + encoderArgs = [] + } else { + encoderArgs = ['-af', encoderArgsFilters.join(',')] + } + const newStream = ytdl(queue.playing.url, { + filter: 'audioonly', + opusEncoded: true, + encoderArgs, + seek: currentStreamTime + queue.additionalStreamTime + }) + setTimeout(() => { + if (queue.stream) queue.stream.destroy() + queue.stream = newStream + queue.voiceConnection.play(newStream, { + type: 'opus', + bitrate: 'auto' + }) + if (currentStreamTime) { + queue.additionalStreamTime += currentStreamTime + } + queue.voiceConnection.dispatcher.setVolumeLogarithmic(queue.calculatedVolume / 200) + // When the track starts + queue.voiceConnection.dispatcher.on('start', () => { + resolve() + }) + // When the track ends + queue.voiceConnection.dispatcher.on('finish', () => { + // Reset streamTime + queue.additionalStreamTime = 0 + // Play the next track + return this._playTrack(queue.guildID, false) + }) + }, 1000) + }) + } + + /** + * Start playing a track in a guild + * @ignore + * @private + * @param {Discord.Snowflake} guildID + * @param {Boolean} firstPlay Whether the function was called from the play() one + */ + async _playTrack (guildID, firstPlay) { + // Get guild queue + const queue = this.queues.find((g) => g.guildID === guildID) + // If there isn't any music in the queue + if (queue.tracks.length < 1 && !firstPlay && !queue.repeatMode) { + // Leave the voice channel + if (this.options.leaveOnEnd && !queue.stopped) queue.voiceConnection.channel.leave() + // Remove the guild from the guilds list + this.queues = this.queues.filter((g) => g.guildID !== guildID) + // Emit stop event + if (queue.stopped) { + if (this.options.leaveOnStop) queue.voiceConnection.channel.leave() + return queue.emit('stop') + } + // Emit end event + return queue.emit('end') + } + const wasPlaying = queue.playing + const nowPlaying = queue.playing = queue.repeatMode ? wasPlaying : queue.tracks.shift() + // Reset lastSkipped state + queue.lastSkipped = false + this._playYTDLStream(queue, false).then(() => { + // Emit trackChanged event + if (!firstPlay) { + queue.emit('trackChanged', wasPlaying, nowPlaying, queue.lastSkipped, queue.repeatMode) + } + }) + } }; -module.exports = Player; \ No newline at end of file +module.exports = Player diff --git a/src/Queue.js b/src/Queue.js index dea9924..a3735b6 100644 --- a/src/Queue.js +++ b/src/Queue.js @@ -1,68 +1,101 @@ -const { EventEmitter } = require('events'); +const Discord = require('discord.js') +const { EventEmitter } = require('events') +const Track = require('./Track') /** * Represents a guild queue. */ class Queue extends EventEmitter { - /** - * Represents a guild queue. - * @param {string} guildID + * @param {Discord.Snowflake} guildID ID of the guild this queue is for. */ - constructor(guildID){ - super(); + constructor (guildID) { + super() /** - * The guild ID. - * @type {Snowflake} + * ID of the guild this queue is for. + * @type {Discord.Snowflake} */ - this.guildID = guildID; + this.guildID = guildID /** - * The stream dispatcher. - * @type {StreamDispatcher} + * The voice connection of this queue. + * @type {Discord.VoiceConnection} */ - this.dispatcher = null; + this.voiceConnection = null /** - * The voice connection. - * @type {VoiceConnection} + * The song currently played. + * @type {Track} */ - this.connection = null; + this.playing = null /** - * Songs. The first one is currently playing and the rest is going to be played. - * @type {Song[]} + * The tracks of this queue. The first one is currenlty playing and the others are going to be played. + * @type {Track[]} */ - this.songs = []; + this.tracks = [] /** * Whether the stream is currently stopped. - * @type {Boolean} + * @type {boolean} */ - this.stopped = false; + this.stopped = false /** - * Whether the last song was skipped. - * @type {Boolean} + * Whether the last track was skipped. + * @type {boolean} */ - this.skipped = false; + this.lastSkipped = false /** - * The stream volume. - * @type {Number} + * The stream volume of this queue. (0-100) + * @type {number} */ - this.volume = 100; + this.volume = 100 /** - * Whether the stream is currently playing. - * @type {Boolean} + * Whether the stream is currently paused. + * @type {boolean} */ - this.playing = true; + this.paused = true /** * Whether the repeat mode is enabled. - * @type {Boolean} + * @type {boolean} */ - this.repeatMode = false; + this.repeatMode = false + /** + * Filters status + * @type {Filters} + */ + this.filters = {} + /** + * Additional stream time + * @type {Number} + */ + this.additionalStreamTime = 0 } -}; + get calculatedVolume () { + return this.filters.bassboost ? this.volume + 50 : this.volume + } +} + +module.exports = Queue /** * Emitted when the queue is empty. * @event Queue#end + * + * @example + * client.on('message', (message) => { + * + * const args = message.content.slice(settings.prefix.length).trim().split(/ +/g); + * const command = args.shift().toLowerCase(); + * + * if(command === 'play'){ + * + * let track = await client.player.play(message.member.voice.channel, args[0]); + * + * track.queue.on('end', () => { + * message.channel.send('The queue is empty, please add new tracks!'); + * }); + * + * } + * + * }); */ /** @@ -71,11 +104,31 @@ class Queue extends EventEmitter { */ /** - * Emitted when the song changes. - * @event Queue#songChanged - * @param {Song} oldSong The old song (playing before) - * @param {Song} newSong The new song (currently playing) + * Emitted when the track changes. + * @event Queue#trackChanged + * @param {Track} oldTrack The old track (playing before) + * @param {Track} newTrack The new track (currently playing) * @param {Boolean} skipped Whether the change is due to the skip() function + * + * @example + * client.on('message', (message) => { + * + * const args = message.content.slice(settings.prefix.length).trim().split(/ +/g); + * const command = args.shift().toLowerCase(); + * + * if(command === 'play'){ + * + * let track = await client.player.play(message.member.voice.channel, args[0]); + * + * track.queue.on('trackChanged', (oldTrack, newTrack, skipped, repeatMode) => { + * if(repeatMode){ + * message.channel.send(`Playing ${newTrack} again...`); + * } else { + * message.channel.send(`Now playing ${newTrack}...`); + * } + * }); + * + * } + * + * }); */ - -module.exports = Queue; \ No newline at end of file diff --git a/src/Song.js b/src/Song.js deleted file mode 100644 index a168e3e..0000000 --- a/src/Song.js +++ /dev/null @@ -1,58 +0,0 @@ -/** - * Represents a song. - */ -class Song { - /** - * @param {Video} video The Youtube video - * @param {Queue} queue The queue in which the song is - */ - constructor(video, queue, requestedBy) { - /** - * Song name. - * @type {string} - */ - this.name = video.title; - /** - * Song duration. - * @type {Number} - */ - this.duration = ((video.duration.hours*3600)+(video.duration.minutes*60)+(video.duration.seconds)) * 1000; - /** - * Raw video object from Simple Youtube API - * @type {Video} - */ - this.rawVideo = video; - /** - * Raw informations about the song. - * @type {Object} - */ - this.raw = video.raw; - /** - * Author channel of the song. - * @type {string} - */ - this.author = video.raw.snippet.channelTitle; - /** - * Youtube video URL. - * @type {string} - */ - this.url = `https://www.youtube.com/watch?v=${video.id}`; - /** - * Youtube video thumbnail. - * @type {string} - */ - this.thumbnail = video.raw.snippet.thumbnails.default.url; - /** - * The queue in which the song is. - * @type {Queue} - */ - this.queue = queue; - /** - * The user who requested that song. - * @type {User} - */ - this.requestedBy = requestedBy; - } -}; - -module.exports = Song; diff --git a/src/Track.js b/src/Track.js new file mode 100644 index 0000000..91cf172 --- /dev/null +++ b/src/Track.js @@ -0,0 +1,80 @@ +const Discord = require('discord.js') +const Queue = require('./Queue') + +/** + * Represents a track. + */ +class Track { + /** + * @param {Object} videoData The video data for this track + * @param {Discord.User?} user The user who requested the track + * @param {Queue?} queue The queue in which is the track is + */ + constructor (videoData, user, queue) { + /** + * The track name + * @type {string} + */ + this.name = videoData.title + /** + * The Youtube URL of the track + * @type {string} + */ + this.url = videoData.link + /** + * The video duration (formatted). + * @type {string} + */ + this.duration = videoData.duration + /** + * The video description + * @type {string} + */ + this.description = videoData.description + /** + * The video thumbnail + * @type {string} + */ + this.thumbnail = videoData.thumbnail + /** + * The video views + * @type {?number} + */ + this.views = videoData.views + /** + * The video channel + * @type {string} + */ + this.author = videoData.author.name + /** + * The user who requested the track + * @type {Discord.User?} + */ + this.requestedBy = user + /** + * The queue in which the track is + * @type {Queue} + */ + this.queue = queue + } + + /** + * The track duration + * @type {number} + */ + get durationMS () { + const args = this.duration.split(':') + if (args.length === 3) { + return parseInt(args[0]) * 60 * 60 * 1000 + + parseInt(args[1]) * 60 * 1000 + + parseInt(args[2]) * 1000 + } else if (args.length === 2) { + return parseInt(args[0]) * 60 * 1000 + + parseInt(args[1]) * 1000 + } else { + return parseInt(args[0]) * 1000 + } + } +} + +module.exports = Track diff --git a/src/Util.js b/src/Util.js deleted file mode 100644 index 683c198..0000000 --- a/src/Util.js +++ /dev/null @@ -1,49 +0,0 @@ -const fetch = require('node-fetch'); - -/** - * Utilities. - * @ignore - */ -class Util { - - constructor(){} - - /** - * Gets the first youtube results for your search. - * @param {string} search The name of the video or the video URL. - * @param {Youtube} SYA The Simple Youtube API Client. - * @returns {Promise