From 181131f75550a299ba17ee0aef81471d49ed5e41 Mon Sep 17 00:00:00 2001 From: Snowflake107 Date: Sat, 12 Jun 2021 00:04:52 +0545 Subject: [PATCH] basic player --- src/Player.ts | 45 +++++++- src/Structures/Queue.ts | 107 ++++++++++++++++-- src/Structures/Track.ts | 21 +++- ...bscription.ts => BasicStreamDispatcher.ts} | 24 ++-- src/VoiceInterface/VoiceUtils.ts | 14 +-- src/index.ts | 2 +- src/types/types.ts | 23 ++++ src/utils/QueryResolver.ts | 1 - tsconfig.json | 3 +- 9 files changed, 206 insertions(+), 34 deletions(-) rename src/VoiceInterface/{VoiceSubscription.ts => BasicStreamDispatcher.ts} (87%) diff --git a/src/Player.ts b/src/Player.ts index 642cbe8..264021c 100644 --- a/src/Player.ts +++ b/src/Player.ts @@ -1,10 +1,13 @@ -import { Client, Collection, Guild, Snowflake } from "discord.js"; +import { Client, Collection, Guild, Snowflake, User } from "discord.js"; import { TypedEmitter as EventEmitter } from "tiny-typed-emitter"; import { Queue } from "./Structures/Queue"; import { VoiceUtils } from "./VoiceInterface/VoiceUtils"; -import { PlayerOptions } from "./types/types"; +import { PlayerEvents, PlayerOptions, QueryType } from "./types/types"; +import Track from "./Structures/Track"; +import { QueryResolver } from "./utils/QueryResolver"; +import YouTube from "youtube-sr"; -class DiscordPlayer extends EventEmitter { +class DiscordPlayer extends EventEmitter { public readonly client: Client; public readonly queues = new Collection(); public readonly voiceUtils = new VoiceUtils(); @@ -26,6 +29,42 @@ class DiscordPlayer extends EventEmitter { return this.queues.get(guild); } + /** + * Search tracks + * @param {string|Track} query The search query + * @param {User} requestedBy The person who requested track search + * @returns {Promise} + */ + async search(query: string | Track, requestedBy: User) { + if (query instanceof Track) return [query]; + + // @todo: add extractors + const qt = QueryResolver.resolve(query); + switch (qt) { + case QueryType.YOUTUBE: { + const videos = await YouTube.search(qt, { + type: "video" + }); + + return videos.map( + (m) => + new Track(this, { + title: m.title, + description: m.description, + author: m.channel?.name, + url: m.url, + requestedBy: requestedBy, + thumbnail: m.thumbnail?.displayThumbnailURL("maxresdefault"), + views: m.views, + fromPlaylist: false, + duration: m.durationFormatted, + raw: m + }) + ); + } + } + } + *[Symbol.iterator]() { yield* Array.from(this.queues.values()); } diff --git a/src/Structures/Queue.ts b/src/Structures/Queue.ts index 095c1f2..d9a3f82 100644 --- a/src/Structures/Queue.ts +++ b/src/Structures/Queue.ts @@ -1,15 +1,18 @@ import { Guild, StageChannel, VoiceChannel } from "discord.js"; import { Player } from "../Player"; -import { VoiceSubscription } from "../VoiceInterface/VoiceSubscription"; +import { StreamDispatcher } from "../VoiceInterface/BasicStreamDispatcher"; import Track from "./Track"; import { PlayerOptions } from "../types/types"; +import ytdl from "discord-ytdl-core"; +import { AudioResource, StreamType } from "@discordjs/voice"; class Queue { public readonly guild: Guild; public readonly player: Player; - public voiceConnection: VoiceSubscription; + public connection: StreamDispatcher; public tracks: Track[] = []; public options: PlayerOptions; + public playing = false; constructor(player: Player, guild: Guild, options: PlayerOptions = {}) { this.player = player; @@ -29,38 +32,118 @@ class Queue { ytdlDownloadOptions: {}, useSafeSearch: false, disableAutoRegister: false, - fetchBeforeQueued: false + fetchBeforeQueued: false, + initialVolume: 100 } as PlayerOptions, options ); } get current() { - return this.voiceConnection.audioResource?.metadata ?? this.tracks[0]; + return this.connection.audioResource?.metadata ?? this.tracks[0]; } - async joinVoiceChannel(channel: StageChannel | VoiceChannel) { - if (!["stage", "voice"].includes(channel.type)) - throw new TypeError(`Channel type must be voice or stage, got ${channel.type}!`); + async connect(channel: StageChannel | VoiceChannel) { + 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.voiceConnection = connection; + this.connection = connection; return this; } destroy() { - this.voiceConnection.stop(); - this.voiceConnection.disconnect(); + this.connection.end(); + this.connection.disconnect(); this.player.queues.delete(this.guild.id); } - play() { - throw new Error("Not implemented"); + skip() { + if (!this.connection) return false; + return this.connection.end(); + } + + addTrack(track: Track) { + this.addTracks([track]); + } + + addTracks(tracks: Track[]) { + this.tracks.push(...tracks); + } + + async play(src?: Track) { + if (!this.connection || !this.connection.voiceConnection) + throw new Error("Voice connection is not available, use .connect()!"); + const track = src ?? this.tracks.shift(); + if (!track) return; + + let resource: AudioResource; + + if (["youtube", "spotify"].includes(track.raw.source)) { + const stream = ytdl(track.raw.source === "spotify" ? track.raw.engine : track.url, { + // because we don't wanna decode opus into pcm again just for volume, let discord.js handle that + opusEncoded: false, + fmt: "s16le" + }); + + resource = this.connection.createStream(stream, { + type: StreamType.Raw, + data: track + }); + } else { + const 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" + } + ); + + resource = this.connection.createStream(stream, { + type: StreamType.Raw, + data: track + }); + } + + const dispatcher = this.connection.playStream(resource); + dispatcher.setVolume(this.options.initialVolume); + + dispatcher.on("start", () => { + this.playing = true; + this.player.emit("trackStart", this, this.current); + }); + + dispatcher.on("finish", () => { + this.playing = false; + if (!this.tracks.length) { + this.destroy(); + this.player.emit("queueEnd", this); + } else { + const nextTrack = this.tracks.shift(); + this.play(nextTrack); + } + }); } *[Symbol.iterator]() { yield* this.tracks; } + + toJSON() { + return { + guild: this.guild.id, + options: this.options, + tracks: this.tracks.map((m) => m.toJSON()) + }; + } + + toString() { + if (!this.tracks.length) return "No songs available to display!"; + return `**Upcoming Songs:**\n${this.tracks.map((m, i) => `${i + 1}. **${m.title}**`).join("\n")}`; + } } export { Queue }; diff --git a/src/Structures/Track.ts b/src/Structures/Track.ts index ee5e79e..bb9f4fd 100644 --- a/src/Structures/Track.ts +++ b/src/Structures/Track.ts @@ -104,7 +104,7 @@ class Track { this.fromPlaylist = Boolean(data.fromPlaylist); // raw - Object.defineProperty(this, "raw", { get: () => data, enumerable: false }); + Object.defineProperty(this, "raw", { get: () => data.raw ?? data, enumerable: false }); } /** @@ -148,6 +148,25 @@ class Track { toString(): string { return `${this.title} by ${this.author}`; } + + /** + * Raw JSON representation of this track + * @returns {Object} + */ + toJSON() { + return { + title: this.title, + description: this.description, + author: this.author, + url: this.url, + thumbnail: this.thumbnail, + duration: this.duration, + durationMS: this.durationMS, + views: this.views, + requested: this.requestedBy.id, + fromPlaylist: this.fromPlaylist + }; + } } export default Track; diff --git a/src/VoiceInterface/VoiceSubscription.ts b/src/VoiceInterface/BasicStreamDispatcher.ts similarity index 87% rename from src/VoiceInterface/VoiceSubscription.ts rename to src/VoiceInterface/BasicStreamDispatcher.ts index fb12731..a64ab46 100644 --- a/src/VoiceInterface/VoiceSubscription.ts +++ b/src/VoiceInterface/BasicStreamDispatcher.ts @@ -22,7 +22,7 @@ export interface VoiceEvents { finish: () => any; } -class VoiceSubscription extends EventEmitter { +class BasicStreamDispatcher extends EventEmitter { public readonly voiceConnection: VoiceConnection; public readonly audioPlayer: AudioPlayer; public connectPromise?: Promise; @@ -46,7 +46,7 @@ class VoiceSubscription extends EventEmitter { this.voiceConnection.destroy(); } } else if (newState.status === VoiceConnectionStatus.Destroyed) { - this.stop(); + this.end(); } else if ( !this.connectPromise && (newState.status === VoiceConnectionStatus.Connecting || @@ -64,6 +64,7 @@ class VoiceSubscription extends EventEmitter { this.audioPlayer.on("stateChange", (oldState, newState) => { if (newState.status === AudioPlayerStatus.Idle && oldState.status !== AudioPlayerStatus.Idle) { + this.audioResource = null; void this.emit("finish"); } else if (newState.status === AudioPlayerStatus.Playing) { void this.emit("start"); @@ -78,14 +79,14 @@ class VoiceSubscription extends EventEmitter { /** * Creates stream * @param {Readable|Duplex|string} src The stream source - * @param {({type?:StreamType;data?:any;inlineVolume?:boolean})} [ops] Options + * @param {({type?:StreamType;data?:any;})} [ops] Options * @returns {AudioResource} */ - createStream(src: Readable | Duplex | string, ops?: { type?: StreamType; data?: any; inlineVolume?: boolean }) { + createStream(src: Readable | Duplex | string, ops?: { type?: StreamType; data?: any }) { this.audioResource = createAudioResource(src, { inputType: ops?.type ?? StreamType.Arbitrary, metadata: ops?.data, - inlineVolume: Boolean(ops?.inlineVolume) + inlineVolume: true // we definitely need volume controls, right? }); return this.audioResource; @@ -108,7 +109,7 @@ class VoiceSubscription extends EventEmitter { /** * Stops the player */ - stop() { + end() { this.audioPlayer.stop(); } @@ -126,16 +127,23 @@ class VoiceSubscription extends EventEmitter { */ playStream(resource: AudioResource = this.audioResource) { if (!resource) throw new PlayerError("Audio resource is not available!"); - if (!this.audioResource && resource) this.audioResource = resource; + if (!this.audioResource) this.audioResource = resource; this.audioPlayer.play(resource); return this; } + setVolume(value: number) { + if (!this.audioResource) return; + + // ye boi logarithmic ✌ + this.audioResource.volume.setVolumeLogarithmic(value / 200); + } + get streamTime() { if (!this.audioResource) return 0; return this.audioResource.playbackDuration; } } -export { VoiceSubscription }; +export { BasicStreamDispatcher as StreamDispatcher }; diff --git a/src/VoiceInterface/VoiceUtils.ts b/src/VoiceInterface/VoiceUtils.ts index 093af34..7db11ad 100644 --- a/src/VoiceInterface/VoiceUtils.ts +++ b/src/VoiceInterface/VoiceUtils.ts @@ -1,15 +1,15 @@ import { VoiceChannel, StageChannel, Collection, Snowflake } from "discord.js"; import { entersState, joinVoiceChannel, VoiceConnection, VoiceConnectionStatus } from "@discordjs/voice"; -import { VoiceSubscription } from "./VoiceSubscription"; +import { StreamDispatcher } from "./BasicStreamDispatcher"; class VoiceUtils { - public cache = new Collection(); + public cache = new Collection(); /** * Joins a voice channel * @param {StageChannel|VoiceChannel} channel The voice channel * @param {({deaf?: boolean;maxTime?: number;})} [options] Join options - * @returns {Promise} + * @returns {Promise} */ public async connect( channel: VoiceChannel | StageChannel, @@ -17,7 +17,7 @@ class VoiceUtils { deaf?: boolean; maxTime?: number; } - ): Promise { + ): Promise { let conn = joinVoiceChannel({ guildId: channel.guild.id, channelId: channel.id, @@ -27,7 +27,7 @@ class VoiceUtils { try { conn = await entersState(conn, VoiceConnectionStatus.Ready, options?.maxTime ?? 20000); - const sub = new VoiceSubscription(conn); + const sub = new StreamDispatcher(conn); this.cache.set(channel.guild.id, sub); return sub; } catch (err) { @@ -40,8 +40,8 @@ class VoiceUtils { * Disconnects voice connection * @param {VoiceConnection} connection The voice connection */ - public disconnect(connection: VoiceConnection | VoiceSubscription) { - if (connection instanceof VoiceSubscription) return connection.voiceConnection.destroy(); + public disconnect(connection: VoiceConnection | StreamDispatcher) { + if (connection instanceof StreamDispatcher) return connection.voiceConnection.destroy(); return connection.destroy(); } diff --git a/src/index.ts b/src/index.ts index 5061c5a..60a36d7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ export { AudioFilters } from "./utils/AudioFilters"; export { PlayerError } from "./utils/PlayerError"; export { VoiceUtils } from "./VoiceInterface/VoiceUtils"; -export { VoiceEvents, VoiceSubscription } from "./VoiceInterface/VoiceSubscription"; +export { VoiceEvents, StreamDispatcher } from "./VoiceInterface/BasicStreamDispatcher"; diff --git a/src/types/types.ts b/src/types/types.ts index 414ba9e..ca5d9d1 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -1,6 +1,8 @@ import { User } from "discord.js"; import { downloadOptions } from "ytdl-core"; import { Readable, Duplex } from "stream"; +import { Queue } from "../Structures/Queue"; +import Track from "../Structures/Track"; export type FiltersName = keyof QueueFilters; @@ -50,6 +52,7 @@ export interface RawTrackData { source?: TrackSource; engine?: any; live?: boolean; + raw?: any; } export interface TimeData { @@ -79,6 +82,7 @@ export interface PlayerOptions { useSafeSearch?: boolean; disableAutoRegister?: boolean; fetchBeforeQueued?: boolean; + initialVolume?: number; } export interface ExtractorModelData { @@ -110,3 +114,22 @@ export enum QueryType { REVERBNATION = "reverbnation", YOUTUBE_SEARCH = "youtube_search" } + +export interface PlayerEvents { + botDisconnect: () => any; + channelEmpty: () => any; + connectionCreate: () => any; + error: () => any; + musicStop: () => any; + noResults: () => any; + playlistAdd: () => any; + playlistParseEnd: () => any; + playlistParseStart: () => any; + queueCreate: () => any; + queueEnd: (queue: Queue) => any; + searchCancel: () => any; + searchInvalidResponse: () => any; + searchResults: () => any; + trackAdd: () => any; + trackStart: (queue: Queue, track: Track) => any; +} diff --git a/src/utils/QueryResolver.ts b/src/utils/QueryResolver.ts index 1e7544a..59ba9ef 100644 --- a/src/utils/QueryResolver.ts +++ b/src/utils/QueryResolver.ts @@ -18,7 +18,6 @@ const attachmentRegex = // scary things above *sigh* class QueryResolver { - static resolve(query: string): QueryType { if (SoundcloudValidateURL(query, "track")) return QueryType.SOUNDCLOUD_TRACK; if (SoundcloudValidateURL(query, "playlist") || query.includes("/sets/")) return QueryType.SOUNDCLOUD_PLAYLIST; diff --git a/tsconfig.json b/tsconfig.json index 629bcf8..5fc7c84 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,7 +6,8 @@ "outDir": "./lib", "strict": true, "strictNullChecks": false, - "esModuleInterop": true + "esModuleInterop": true, + "removeComments": true }, "include": [ "src/**/*"