basic player

This commit is contained in:
Snowflake107 2021-06-12 00:04:52 +05:45
parent 5ba21c3337
commit 181131f755
9 changed files with 206 additions and 34 deletions

View file

@ -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<PlayerEvents> {
public readonly client: Client;
public readonly queues = new Collection<Snowflake, Queue>();
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<Track[]>}
*/
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());
}

View file

@ -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 <Queue>.connect()!");
const track = src ?? this.tracks.shift();
if (!track) return;
let resource: AudioResource<Track>;
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 };

View file

@ -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;

View file

@ -22,7 +22,7 @@ export interface VoiceEvents {
finish: () => any;
}
class VoiceSubscription extends EventEmitter<VoiceEvents> {
class BasicStreamDispatcher extends EventEmitter<VoiceEvents> {
public readonly voiceConnection: VoiceConnection;
public readonly audioPlayer: AudioPlayer;
public connectPromise?: Promise<void>;
@ -46,7 +46,7 @@ class VoiceSubscription extends EventEmitter<VoiceEvents> {
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<VoiceEvents> {
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<VoiceEvents> {
/**
* 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<VoiceEvents> {
/**
* Stops the player
*/
stop() {
end() {
this.audioPlayer.stop();
}
@ -126,16 +127,23 @@ class VoiceSubscription extends EventEmitter<VoiceEvents> {
*/
playStream(resource: AudioResource<Track> = 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 };

View file

@ -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<Snowflake, VoiceSubscription>();
public cache = new Collection<Snowflake, StreamDispatcher>();
/**
* Joins a voice channel
* @param {StageChannel|VoiceChannel} channel The voice channel
* @param {({deaf?: boolean;maxTime?: number;})} [options] Join options
* @returns {Promise<VoiceSubscription>}
* @returns {Promise<BasicStreamDispatcher>}
*/
public async connect(
channel: VoiceChannel | StageChannel,
@ -17,7 +17,7 @@ class VoiceUtils {
deaf?: boolean;
maxTime?: number;
}
): Promise<VoiceSubscription> {
): Promise<StreamDispatcher> {
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();
}

View file

@ -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";

View file

@ -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;
}

View file

@ -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;

View file

@ -6,7 +6,8 @@
"outDir": "./lib",
"strict": true,
"strictNullChecks": false,
"esModuleInterop": true
"esModuleInterop": true,
"removeComments": true
},
"include": [
"src/**/*"