diff --git a/.prettierrc b/.prettierrc index a8cf444..46a28f7 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,5 +1,5 @@ { - "printWidth": 120, + "printWidth": 200, "trailingComma": "none", "singleQuote": false, "tabWidth": 4, diff --git a/example/config.example.ts b/example/config.example.ts index 1e2b5af..83c37ee 100644 --- a/example/config.example.ts +++ b/example/config.example.ts @@ -1,3 +1,3 @@ export const config = { token: "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" -}; \ No newline at end of file +}; diff --git a/example/index.ts b/example/index.ts index 24e9d48..5972a84 100644 --- a/example/index.ts +++ b/example/index.ts @@ -1,79 +1,200 @@ -import { Client, Message } from "discord.js"; -import { Player, Queue } from "../src/index"; +import { Client, GuildMember, Message, TextChannel } from "discord.js"; +import { Player, Queue, Track } from "../src/index"; import { config } from "./config"; +// use this in prod. +// import { Player, Queue } from "discord-player"; const client = new Client({ - intents: ['GUILD_VOICE_STATES', 'GUILD_MESSAGES', 'GUILDS'] + intents: ["GUILD_VOICE_STATES", "GUILD_MESSAGES", "GUILDS"] }); + +client.on("ready", () => { + console.log("Bot is online!"); + client.user.setActivity({ + name: "🎶 | Music Time", + type: "LISTENING" + }); +}); +client.on("error", console.error); +client.on("warn", console.warn); + +// instantiate the player const player = new Player(client); +player.on("error", console.error); + player.on("trackStart", (queue, track) => { - const guildQueue = queue as Queue; - guildQueue.metadata.channel.send(`🎶 | Now playing: **${track.title}** in **${guildQueue.connection.channel.name}**!`); + const guildQueue = queue as Queue; + guildQueue.metadata.send(`🎶 | Started playing: **${track.title}** in **${guildQueue.connection.channel.name}**!`); }); -client.on("ready", () => console.log("Bot is online!")); +player.on("trackAdd", (queue, track) => { + const guildQueue = queue as Queue; + guildQueue.metadata.send(`🎶 | Track **${track.title}** queued!`); +}); -client.on("message", async message => { - if (!client.application.owner) await client.application.fetch(); - if (message.author.id !== client.application.owner.id) return; +client.on("message", async (message) => { + if (message.author.bot || !message.guild) return; + if (!client.application?.owner) await client.application?.fetch(); - if (message.content.startsWith("!np") && message.guild.me.voice.channelID) { - const conn = player.getQueue(message.guild.id); - if (!conn) return; - return void message.channel.send(`Now Playing: **${conn.current.title}** (Played **${Math.floor(conn.connection.streamTime / 1000)} seconds**)`); + if (message.content === "!deploy" && message.author.id === client.application?.owner?.id) { + await message.guild.commands.set([ + { + name: "play", + description: "Plays a song from youtube", + options: [ + { + name: "query", + type: "STRING", + description: "The song you want to play", + required: true + } + ] + }, + { + name: "volume", + description: "Sets music volume", + options: [ + { + name: "amount", + type: "INTEGER", + description: "The volume amount to set (0-100)", + required: false + } + ] + }, + { + name: "skip", + description: "Skip to the current song" + }, + { + name: "queue", + description: "See the queue" + }, + { + name: "pause", + description: "Pause the current song" + }, + { + name: "resume", + description: "Resume the current song" + }, + { + name: "stop", + description: "Stop the player" + }, + { + name: "np", + description: "Now Playing" + } + ]); + + await message.reply("Deployed!"); + } +}); + +client.on("interaction", async (interaction) => { + if (!interaction.isCommand() || !interaction.guildID) return; + + if (!(interaction.member instanceof GuildMember) || !interaction.member.voice.channel) { + return void interaction.reply({ content: "You are not in a voice channel!", ephemeral: true }); } - if (message.content.startsWith("!pause") && message.guild.me.voice.channelID) { - const conn = player.getQueue(message.guild.id); - if (!conn) return; - conn.setPaused(true); - return void message.channel.send("Paused!"); + if (interaction.guild.me.voice.channelID && interaction.member.voice.channelID !== interaction.guild.me.voice.channelID) { + return void interaction.reply({ content: "You are not in my voice channel!", ephemeral: true }); } - if (message.content.startsWith("!resume") && message.guild.me.voice.channelID) { - const conn = player.getQueue(message.guild.id); - if (!conn) return; - conn.setPaused(false); - return void message.channel.send("Resumed!"); - } + if (interaction.commandName === "play") { + await interaction.defer(); - if (message.content.startsWith("!skip") && message.guild.me.voice.channelID) { - const conn = player.getQueue(message.guild.id); - if (!conn) return; - conn.skip(); - return void message.channel.send("Done!"); - } + const query = interaction.options.get("query")!.value! as string; + const searchResult = (await player.search(query, interaction.user).catch(() => [])) as Track[]; + if (!searchResult.length) return void interaction.followUp({ content: "No results were found!" }); - if (message.content.startsWith("!queue") && message.guild.me.voice.channelID) { - const conn = player.getQueue(message.guild.id); - if (!conn) return; - return void message.channel.send({ content: conn.toString(), split: true }); - } - - if (message.content.startsWith("!vol") && message.guild.me.voice.channelID) { - const conn = player.getQueue(message.guild.id); - if (!conn) return; - conn.connection.setVolume(parseInt(message.content.slice(4).trim())); - return void message.channel.send("Volume changed!"); - } - - if (message.content.startsWith("!p") && message.member.voice.channelID) { - const queue = player.createQueue(message.guild, { - metadata: message + const queue = await player.createQueue(interaction.guild, { + metadata: interaction.channel }); - const song = await player.search(message.content.slice(2).trim(), message.author).then(x => x[0]); - queue.addTrack(song); - if (!queue.connection) { - queue.connect(message.member.voice.channel) - .then(async q => { - await q.play(); - }); - } else { - message.channel.send(`🎶 | Queued: **${song.title}**!`); + try { + if (!queue.connection) await queue.connect(interaction.member.voice.channel); + } catch { + void player.deleteQueue(interaction.guildID); + return void interaction.followUp({ content: "Could not join your voice channel!" }); } + + await interaction.followUp({ content: "⏱ | Loading your track..." }); + await queue.play(searchResult[0]); + } else if (interaction.commandName === "volume") { + await interaction.defer(); + const queue = player.getQueue(interaction.guildID); + if (!queue || !queue.playing) return void interaction.followUp({ content: "❌ | No music is being played!" }); + const vol = interaction.options.get("amount"); + if (!vol) return void interaction.followUp({ content: `🎧 | Current volume is **${queue.volume}**%!` }); + if ((vol.value as number) < 0 || (vol.value as number) > 100) return void interaction.followUp({ content: "❌ | Volume range must be 0-100" }); + const success = queue.setVolume(vol.value as number); + return void interaction.followUp({ + content: success ? `✅ | Volume set to **${vol.value}%**!` : "❌ | Something went wrong!" + }); + } else if (interaction.commandName === "skip") { + await interaction.defer(); + const queue = player.getQueue(interaction.guildID); + if (!queue || !queue.playing) return void interaction.followUp({ content: "❌ | No music is being played!" }); + const currentTrack = queue.current; + const success = queue.skip(); + return void interaction.followUp({ + content: success ? `✅ | Skipped **${currentTrack}**!` : "❌ | Something went wrong!" + }); + } else if (interaction.commandName === "queue") { + await interaction.defer(); + const queue = player.getQueue(interaction.guildID); + if (!queue || !queue.playing) return void interaction.followUp({ content: "❌ | No music is being played!" }); + const currentTrack = queue.current; + const tracks = queue.tracks + .slice(0, 10) + .map((m, i) => { + return `${i + 1}. **${m.title}**`; + }) + .join("\n"); + + return void interaction.followUp({ + embeds: [ + { + title: "Server Queue", + description: `${tracks}${queue.tracks.length > tracks.length ? `...${queue.tracks.length - tracks.length} more tracks` : ""}`, + color: 0xff0000, + fields: [{ name: "Now Playing", value: `🎶 | **${currentTrack.title}**` }] + } + ] + }); + } else if (interaction.commandName === "pause") { + await interaction.defer(); + const queue = player.getQueue(interaction.guildID); + if (!queue || !queue.playing) return void interaction.followUp({ content: "❌ | No music is being played!" }); + const success = queue.setPaused(true); + return void interaction.followUp({ content: success ? "⏸ | Paused!" : "❌ | Something went wrong!" }); + } else if (interaction.commandName === "resume") { + await interaction.defer(); + const queue = player.getQueue(interaction.guildID); + if (!queue || !queue.playing) return void interaction.followUp({ content: "❌ | No music is being played!" }); + const success = queue.setPaused(false); + return void interaction.followUp({ content: success ? "▶ | Resumed!" : "❌ | Something went wrong!" }); + } else if (interaction.commandName === "stop") { + await interaction.defer(); + const queue = player.getQueue(interaction.guildID); + if (!queue || !queue.playing) return void interaction.followUp({ content: "❌ | No music is being played!" }); + queue.destroy(); + return void interaction.followUp({ content: "🛑 | Stopped the player!" }); + } else if (interaction.commandName === "np") { + await interaction.defer(); + const queue = player.getQueue(interaction.guildID); + if (!queue || !queue.playing) return void interaction.followUp({ content: "❌ | No music is being played!" }); + return void interaction.followUp({ content: `🎶 | Current song: **${queue.current.title}**!` }); + } else { + interaction.reply({ + content: "Unknown command!", + ephemeral: true + }); } }); -client.login(config.token); \ No newline at end of file +client.login(config.token); diff --git a/package.json b/package.json index b9bb6e8..a5c4876 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,8 @@ ], "scripts": { "test": "cd example && ts-node index.ts", - "build": "tsc", - "format": "prettier --write \"src/**/*.ts\"", + "build": "rimraf lib && tsc", + "format": "prettier --write \"src/**/*.ts\" \"example/**/*.ts\"", "lint": "tslint -p tsconfig.json", "docs": "docgen --jsdoc jsdoc.json --verbose --source src/*.ts src/**/*.ts --custom docs/index.yml --output docs/docs.json", "docs:test": "docgen --jsdoc jsdoc.json --verbose --source src/*.ts src/**/*.ts --custom docs/index.yml" @@ -68,10 +68,11 @@ "@types/node": "^14.14.41", "@types/ws": "^7.4.1", "discord-api-types": "^0.18.1", - "discord.js": "^13.0.0-dev.f5f3f772865ee98bbb44df938e0e71f9f8865c10", + "discord.js": "^13.0.0-dev.02693bc02f45980d8165820a103220f0027b96b7", "discord.js-docgen": "discordjs/docgen#ts-patch", "jsdoc-babel": "^0.5.0", "prettier": "^2.2.1", + "rimraf": "^3.0.2", "ts-node": "^10.0.0", "tslint": "^6.1.3", "tslint-config-prettier": "^1.18.0", diff --git a/src/Player.ts b/src/Player.ts index 72f02e9..f76a63c 100644 --- a/src/Player.ts +++ b/src/Player.ts @@ -31,6 +31,12 @@ class DiscordPlayer extends EventEmitter { return this.queues.get(guild) as Queue; } + deleteQueue(guild: Snowflake) { + const prev = this.getQueue(guild); + this.queues.delete(guild); + return prev; + } + /** * Search tracks * @param {string|Track} query The search query diff --git a/src/Structures/Queue.ts b/src/Structures/Queue.ts index 2ae4f21..8cca1cd 100644 --- a/src/Structures/Queue.ts +++ b/src/Structures/Queue.ts @@ -45,8 +45,7 @@ class Queue { } async connect(channel: StageChannel | VoiceChannel) { - if (!["stage", "voice"].includes(channel?.type)) - throw new TypeError(`Channel type must be voice or stage, got ${channel?.type}!`); + if (!["stage", "voice"].includes(channel?.type)) throw new TypeError(`Channel type must be voice or stage, got ${channel?.type}!`); const connection = await this.player.voiceUtils.connect(channel); this.connection = connection; @@ -69,6 +68,7 @@ class Queue { addTrack(track: Track) { this.addTracks([track]); + this.player.emit("trackAdd", this, track); } addTracks(tracks: Track[]) { @@ -86,12 +86,21 @@ class Queue { this.connection.audioResource.encoder.setBitrate(bitrate); } + setVolume(amount: number) { + if (!this.connection) return false; + return this.connection.setVolume(amount); + } + + get volume() { + if (!this.connection) return 100; + return this.connection.volume; + } + async play(src?: Track, options: PlayOptions = {}) { - if (!this.connection || !this.connection.voiceConnection) - throw new Error("Voice connection is not available, use .connect()!"); + if (!this.connection || !this.connection.voiceConnection) throw new Error("Voice connection is not available, use .connect()!"); + if (src && (this.playing || this.tracks.length) && !options.immediate) return this.addTrack(src); const track = options.filtersUpdate ? this.current : src ?? this.tracks.shift(); if (!track) return; - let stream; if (["youtube", "spotify"].includes(track.raw.source)) { stream = ytdl(track.raw.source === "spotify" ? track.raw.engine : track.url, { @@ -102,18 +111,13 @@ class Queue { seek: options.seek }); } else { - stream = ytdl.arbitraryStream( - track.raw.source === "soundcloud" - ? await track.raw.engine.downloadProgressive() - : (track.raw.engine as string), - { - // because we don't wanna decode opus into pcm again just for volume, let discord.js handle that - opusEncoded: false, - fmt: "s16le", - encoderArgs: options.encoderArgs ?? [], - seek: options.seek - } - ); + stream = ytdl.arbitraryStream(track.raw.source === "soundcloud" ? await track.raw.engine.downloadProgressive() : (track.raw.engine as string), { + // because we don't wanna decode opus into pcm again just for volume, let discord.js handle that + opusEncoded: false, + fmt: "s16le", + encoderArgs: options.encoderArgs ?? [], + seek: options.seek + }); } const resource: AudioResource = this.connection.createStream(stream, { @@ -124,12 +128,12 @@ class Queue { const dispatcher = await this.connection.playStream(resource); dispatcher.setVolume(this.options.initialVolume); - dispatcher.on("start", () => { + dispatcher.once("start", () => { this.playing = true; if (!options.filtersUpdate) this.player.emit("trackStart", this, this.current); }); - dispatcher.on("finish", () => { + dispatcher.once("finish", () => { this.playing = false; if (options.filtersUpdate) return; @@ -138,9 +142,12 @@ class Queue { this.player.emit("queueEnd", this); } else { const nextTrack = this.tracks.shift(); - this.play(nextTrack); + this.play(nextTrack, { immediate: true }); } }); + + dispatcher.on("error", (e) => this.player.emit("error", this, e)); + dispatcher.on("debug", (msg) => this.player.emit("debug", this, msg)); } *[Symbol.iterator]() { diff --git a/src/VoiceInterface/BasicStreamDispatcher.ts b/src/VoiceInterface/BasicStreamDispatcher.ts index b95cbc8..7bf8544 100644 --- a/src/VoiceInterface/BasicStreamDispatcher.ts +++ b/src/VoiceInterface/BasicStreamDispatcher.ts @@ -51,16 +51,11 @@ class BasicStreamDispatcher extends EventEmitter { } } else if (newState.status === VoiceConnectionStatus.Destroyed) { this.end(); - } else if ( - !this.connectPromise && - (newState.status === VoiceConnectionStatus.Connecting || - newState.status === VoiceConnectionStatus.Signalling) - ) { + } else if (!this.connectPromise && (newState.status === VoiceConnectionStatus.Connecting || newState.status === VoiceConnectionStatus.Signalling)) { this.connectPromise = entersState(this.voiceConnection, VoiceConnectionStatus.Ready, 20000) .then(() => undefined) .catch(() => { - if (this.voiceConnection.state.status !== VoiceConnectionStatus.Destroyed) - this.voiceConnection.destroy(); + if (this.voiceConnection.state.status !== VoiceConnectionStatus.Destroyed) this.voiceConnection.destroy(); }) .finally(() => (this.connectPromise = undefined)); } @@ -140,8 +135,7 @@ class BasicStreamDispatcher extends EventEmitter { async playStream(resource: AudioResource = this.audioResource) { if (!resource) throw new PlayerError("Audio resource is not available!"); if (!this.audioResource) this.audioResource = resource; - if (this.voiceConnection.state.status !== VoiceConnectionStatus.Ready) - await entersState(this.voiceConnection, VoiceConnectionStatus.Ready, 20000); + if (this.voiceConnection.state.status !== VoiceConnectionStatus.Ready) await entersState(this.voiceConnection, VoiceConnectionStatus.Ready, 20000); this.audioPlayer.play(resource); return this; @@ -155,6 +149,12 @@ class BasicStreamDispatcher extends EventEmitter { return true; } + get volume() { + if (!this.audioResource || !this.audioResource.volume) return 100; + const currentVol = this.audioResource.volume.volume; + return Math.round(Math.pow(currentVol, 1 / 1.660964) * 200); + } + get streamTime() { if (!this.audioResource) return 0; return this.audioResource.playbackDuration; diff --git a/src/types/types.ts b/src/types/types.ts index 8c14795..815b0bd 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -119,7 +119,8 @@ export interface PlayerEvents { botDisconnect: () => any; channelEmpty: () => any; connectionCreate: () => any; - error: () => any; + debug: (queue: Queue, message: string) => any; + error: (queue: Queue, error: Error) => any; musicStop: () => any; noResults: () => any; playlistAdd: () => any; @@ -130,7 +131,7 @@ export interface PlayerEvents { searchCancel: () => any; searchInvalidResponse: () => any; searchResults: () => any; - trackAdd: () => any; + trackAdd: (queue: Queue, track: Track) => any; trackStart: (queue: Queue, track: Track) => any; } @@ -143,4 +144,7 @@ export interface PlayOptions { /** Time to seek to before playing */ seek?: number; + + /** If it should start playing provided track immediately */ + immediate?: boolean; } diff --git a/src/utils/AudioFilters.ts b/src/utils/AudioFilters.ts index 7ebf8e6..9af7bbb 100644 --- a/src/utils/AudioFilters.ts +++ b/src/utils/AudioFilters.ts @@ -70,15 +70,11 @@ const FilterList = { }, get names() { - return Object.keys(this).filter( - (p) => !["names", "length"].includes(p) && typeof this[p as FiltersName] !== "function" - ); + return Object.keys(this).filter((p) => !["names", "length"].includes(p) && typeof this[p as FiltersName] !== "function"); }, get length() { - return Object.keys(this).filter( - (p) => !["names", "length"].includes(p) && typeof this[p as FiltersName] !== "function" - ).length; + return Object.keys(this).filter((p) => !["names", "length"].includes(p) && typeof this[p as FiltersName] !== "function").length; }, toString() { diff --git a/src/utils/QueryResolver.ts b/src/utils/QueryResolver.ts index 59ba9ef..918b845 100644 --- a/src/utils/QueryResolver.ts +++ b/src/utils/QueryResolver.ts @@ -6,11 +6,9 @@ import { validateURL as SoundcloudValidateURL } from "soundcloud-scraper"; // scary things below *sigh* const spotifySongRegex = /https?:\/\/(?:embed\.|open\.)(?:spotify\.com\/)(?:track\/|\?uri=spotify:track:)((\w|-){22})/; -const spotifyPlaylistRegex = - /https?:\/\/(?:embed\.|open\.)(?:spotify\.com\/)(?:playlist\/|\?uri=spotify:playlist:)((\w|-){22})/; +const spotifyPlaylistRegex = /https?:\/\/(?:embed\.|open\.)(?:spotify\.com\/)(?:playlist\/|\?uri=spotify:playlist:)((\w|-){22})/; const spotifyAlbumRegex = /https?:\/\/(?:embed\.|open\.)(?:spotify\.com\/)(?:album\/|\?uri=spotify:album:)((\w|-){22})/; -const vimeoRegex = - /(http|https)?:\/\/(www\.|player\.)?vimeo\.com\/(?:channels\/(?:\w+\/)?|groups\/([^/]*)\/videos\/|video\/|)(\d+)(?:|\/\?)/; +const vimeoRegex = /(http|https)?:\/\/(www\.|player\.)?vimeo\.com\/(?:channels\/(?:\w+\/)?|groups\/([^/]*)\/videos\/|video\/|)(\d+)(?:|\/\?)/; const facebookRegex = /(https?:\/\/)(www\.|m\.)?(facebook|fb).com\/.*\/videos\/.*/; const reverbnationRegex = /https:\/\/(www.)?reverbnation.com\/(.+)\/song\/(.+)/; const attachmentRegex = diff --git a/yarn.lock b/yarn.lock index 5cf7134..139d780 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1860,10 +1860,10 @@ discord.js-docgen@discordjs/docgen#ts-patch: tsubaki "^1.3.2" yargs "^14.0.0" -discord.js@^13.0.0-dev.f5f3f772865ee98bbb44df938e0e71f9f8865c10: - version "13.0.0-dev.f5f3f772865ee98bbb44df938e0e71f9f8865c10" - resolved "https://registry.yarnpkg.com/discord.js/-/discord.js-13.0.0-dev.f5f3f772865ee98bbb44df938e0e71f9f8865c10.tgz#0fe3389e4befffd429ba37fc669b9153e87f33e5" - integrity sha512-VuGJMzXStqeeHmB3DDEShGHXGey215573kkbFxQinzrFYt1UNnQZ5d1ZmB7Y0oY3JSivpv3Whc5M/YtH41NMXQ== +discord.js@^13.0.0-dev.02693bc02f45980d8165820a103220f0027b96b7: + version "13.0.0-dev.02693bc02f45980d8165820a103220f0027b96b7" + resolved "https://registry.yarnpkg.com/discord.js/-/discord.js-13.0.0-dev.02693bc02f45980d8165820a103220f0027b96b7.tgz#5baa6c758b970a1e6175d06373d4ab33eb6a4164" + integrity sha512-nzbmF5MLSjpdr8DS7SpC9q291NQ8XkHlledM4lz+uFJ4YgnMmTpi6e0FgF7v2kpTJPqTsXDRY3rWBv6Dat+08A== dependencies: "@discordjs/collection" "^0.1.6" "@discordjs/form-data" "^3.0.1"