basic player
This commit is contained in:
parent
5ba21c3337
commit
181131f755
9 changed files with 206 additions and 34 deletions
|
@ -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());
|
||||
}
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 };
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -6,7 +6,8 @@
|
|||
"outDir": "./lib",
|
||||
"strict": true,
|
||||
"strictNullChecks": false,
|
||||
"esModuleInterop": true
|
||||
"esModuleInterop": true,
|
||||
"removeComments": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
|
|
Loading…
Reference in a new issue