aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--package.json1
-rw-r--r--src/bot.ts42
-rw-r--r--src/commands/index.ts8
-rw-r--r--src/commands/play.ts1
-rw-r--r--src/commands/queue.ts106
-rw-r--r--src/inversify.config.ts2
-rw-r--r--src/managers/updating-queue-embed.ts33
-rw-r--r--src/services/player.ts35
-rw-r--r--src/services/updating-queue-embed.ts196
-rw-r--r--src/types.ts1
-rw-r--r--yarn.lock5
11 files changed, 364 insertions, 66 deletions
diff --git a/package.json b/package.json
index 01c6341..f8993f8 100644
--- a/package.json
+++ b/package.json
@@ -48,6 +48,7 @@
"nodemon": "^2.0.7",
"ts-node": "^10.4.0",
"type-fest": "^2.5.4",
+ "typed-emitter": "^1.4.0",
"typescript": "^4.5.3"
},
"eslintConfig": {
diff --git a/src/bot.ts b/src/bot.ts
index f0fb442..6152d2f 100644
--- a/src/bot.ts
+++ b/src/bot.ts
@@ -18,12 +18,14 @@ import {Routes} from 'discord-api-types/v9';
export default class {
private readonly client: Client;
private readonly token: string;
- private readonly commands!: Collection<string, Command>;
+ private readonly commandsByName!: Collection<string, Command>;
+ private readonly commandsByButtonId!: Collection<string, Command>;
constructor(@inject(TYPES.Client) client: Client, @inject(TYPES.Config) config: Config) {
this.client = client;
this.token = config.DISCORD_TOKEN;
- this.commands = new Collection();
+ this.commandsByName = new Collection();
+ this.commandsByButtonId = new Collection();
}
public async listen(): Promise<void> {
@@ -31,7 +33,11 @@ export default class {
container.getAll<Command>(TYPES.Command).forEach(command => {
// TODO: remove !
if (command.slashCommand?.name) {
- this.commands.set(command.slashCommand.name, command);
+ this.commandsByName.set(command.slashCommand.name, command);
+ }
+
+ if (command.handledButtonIds) {
+ command.handledButtonIds.forEach(id => this.commandsByButtonId.set(id, command));
}
});
@@ -41,7 +47,7 @@ export default class {
return;
}
- const command = this.commands.get(interaction.commandName);
+ const command = this.commandsByName.get(interaction.commandName);
if (!command) {
return;
@@ -72,6 +78,32 @@ export default class {
}
});
+ this.client.on('interactionCreate', async interaction => {
+ if (!interaction.isButton()) {
+ return;
+ }
+
+ const command = this.commandsByButtonId.get(interaction.customId);
+
+ if (!command) {
+ return;
+ }
+
+ try {
+ if (command.handleButtonInteraction) {
+ await command.handleButtonInteraction(interaction);
+ }
+ } catch (error: unknown) {
+ debug(error);
+
+ if (interaction.replied || interaction.deferred) {
+ await interaction.editReply(errorMsg('something went wrong'));
+ } else {
+ await interaction.reply({content: errorMsg(error as Error), ephemeral: true});
+ }
+ }
+ });
+
const spinner = ora('📡 connecting to Discord...').start();
this.client.once('ready', () => {
@@ -94,7 +126,7 @@ export default class {
await rest.put(
Routes.applicationGuildCommands(this.client.user!.id, this.client.guilds.cache.first()!.id),
// TODO: remove
- {body: this.commands.map(command => command.slashCommand ? command.slashCommand.toJSON() : null)},
+ {body: this.commandsByName.map(command => command.slashCommand ? command.slashCommand.toJSON() : null)},
);
}
}
diff --git a/src/commands/index.ts b/src/commands/index.ts
index 91b76c5..1cd927b 100644
--- a/src/commands/index.ts
+++ b/src/commands/index.ts
@@ -1,12 +1,14 @@
import {SlashCommandBuilder} from '@discordjs/builders';
-import {CommandInteraction} from 'discord.js';
+import {ButtonInteraction, CommandInteraction} from 'discord.js';
-export default interface Command {
+export default class Command {
// TODO: remove
name?: string;
aliases?: string[];
examples?: string[][];
readonly slashCommand?: Partial<SlashCommandBuilder> & Pick<SlashCommandBuilder, 'toJSON'>;
- requiresVC?: boolean;
+ readonly handledButtonIds?: readonly string[];
+ readonly requiresVC?: boolean;
executeFromInteraction?: (interaction: CommandInteraction) => Promise<void>;
+ handleButtonInteraction?: (interaction: ButtonInteraction) => Promise<void>;
}
diff --git a/src/commands/play.ts b/src/commands/play.ts
index 6e76e37..7dc5e41 100644
--- a/src/commands/play.ts
+++ b/src/commands/play.ts
@@ -15,6 +15,7 @@ import GetSongs from '../services/get-songs.js';
export default class implements Command {
public readonly slashCommand = new SlashCommandBuilder()
.setName('play')
+ // TODO: make sure verb tense is consistent between all command descriptions
.setDescription('play a song or resume playback')
.addStringOption(option => option
.setName('query')
diff --git a/src/commands/queue.ts b/src/commands/queue.ts
index 1c0735c..479bc33 100644
--- a/src/commands/queue.ts
+++ b/src/commands/queue.ts
@@ -1,82 +1,80 @@
-import {Message, MessageEmbed} from 'discord.js';
-import getYouTubeID from 'get-youtube-id';
+import {ButtonInteraction, CommandInteraction} from 'discord.js';
+import {SlashCommandBuilder} from '@discordjs/builders';
import {inject, injectable} from 'inversify';
import {TYPES} from '../types.js';
import PlayerManager from '../managers/player.js';
-import {STATUS} from '../services/player.js';
+import UpdatingQueueEmbedManager from '../managers/updating-queue-embed.js';
+import {BUTTON_IDS} from '../services/updating-queue-embed.js';
import Command from '.';
-import getProgressBar from '../utils/get-progress-bar.js';
-import errorMsg from '../utils/error-msg.js';
-import {prettyTime} from '../utils/time.js';
-
-const PAGE_SIZE = 10;
@injectable()
export default class implements Command {
- public name = 'queue';
- public aliases = ['q'];
- public examples = [
- ['queue', 'shows current queue'],
- ['queue 2', 'shows second page of queue'],
- ];
+ public readonly slashCommand = new SlashCommandBuilder()
+ .setName('queue')
+ .setDescription('show the current queue');
+
+ public readonly handledButtonIds = Object.values(BUTTON_IDS);
private readonly playerManager: PlayerManager;
+ private readonly updatingQueueEmbedManager: UpdatingQueueEmbedManager;
- constructor(@inject(TYPES.Managers.Player) playerManager: PlayerManager) {
+ constructor(@inject(TYPES.Managers.Player) playerManager: PlayerManager, @inject(TYPES.Managers.UpdatingQueueEmbed) updatingQueueEmbedManager: UpdatingQueueEmbedManager) {
this.playerManager = playerManager;
+ this.updatingQueueEmbedManager = updatingQueueEmbedManager;
}
- public async execute(msg: Message, args: string []): Promise<void> {
- const player = this.playerManager.get(msg.guild!.id);
-
- const currentlyPlaying = player.getCurrent();
-
- if (currentlyPlaying) {
- const queueSize = player.queueSize();
- const queuePage = args[0] ? parseInt(args[0], 10) : 1;
-
- const maxQueuePage = Math.ceil((queueSize + 1) / PAGE_SIZE);
+ public async executeFromInteraction(interaction: CommandInteraction) {
+ const embed = this.updatingQueueEmbedManager.get(interaction.guild!.id);
- if (queuePage > maxQueuePage) {
- await msg.channel.send(errorMsg('the queue isn\'t that big'));
- return;
- }
+ await embed.createFromInteraction(interaction);
+ }
- const embed = new MessageEmbed();
+ public async handleButtonInteraction(interaction: ButtonInteraction) {
+ const player = this.playerManager.get(interaction.guild!.id);
+ const embed = this.updatingQueueEmbedManager.get(interaction.guild!.id);
- embed.setTitle(currentlyPlaying.title);
- embed.setURL(`https://www.youtube.com/watch?v=${currentlyPlaying.url.length === 11 ? currentlyPlaying.url : getYouTubeID(currentlyPlaying.url) ?? ''}`);
+ const buttonId = interaction.customId as keyof typeof this.handledButtonIds;
- let description = player.status === STATUS.PLAYING ? '⏹️' : '▶️';
- description += ' ';
- description += getProgressBar(20, player.getPosition() / currentlyPlaying.length);
- description += ' ';
- description += `\`[${prettyTime(player.getPosition())}/${currentlyPlaying.isLive ? 'live' : prettyTime(currentlyPlaying.length)}]\``;
- description += ' 🔉';
- description += player.isQueueEmpty() ? '' : '\n\n**Next up:**';
+ // Not entirely sure why this is necessary.
+ // We don't wait for the Promise to resolve here to avoid blocking the
+ // main logic. However, we need to wait for the Promise to be resolved before
+ // throwing as otherwise a race condition pops up when bot.ts tries updating
+ // the interaction.
+ const deferedUpdatePromise = interaction.deferUpdate();
- embed.setDescription(description);
+ try {
+ switch (buttonId) {
+ case BUTTON_IDS.TRACK_BACK:
+ await player.back();
+ break;
- let footer = `Source: ${currentlyPlaying.artist}`;
+ case BUTTON_IDS.TRACK_FORWARD:
+ await player.forward(1);
+ break;
- if (currentlyPlaying.playlist) {
- footer += ` (${currentlyPlaying.playlist.title})`;
- }
+ case BUTTON_IDS.PAUSE:
+ player.pause();
+ break;
- embed.setFooter(footer);
+ case BUTTON_IDS.PLAY:
+ await player.play();
+ break;
- const queuePageBegin = (queuePage - 1) * PAGE_SIZE;
- const queuePageEnd = queuePageBegin + PAGE_SIZE;
+ case BUTTON_IDS.PAGE_BACK:
+ await embed.pageBack();
+ break;
- player.getQueue().slice(queuePageBegin, queuePageEnd).forEach((song, i) => {
- embed.addField(`${(i + 1 + queuePageBegin).toString()}/${queueSize.toString()}`, song.title, false);
- });
+ case BUTTON_IDS.PAGE_FORWARD:
+ await embed.pageForward();
+ break;
- embed.addField('Page', `${queuePage} out of ${maxQueuePage}`, false);
+ default:
+ throw new Error('unknown customId');
+ }
+ } catch (error: unknown) {
+ await deferedUpdatePromise;
- await msg.channel.send({embeds: [embed]});
- } else {
- await msg.channel.send('queue empty');
+ throw error;
}
}
}
diff --git a/src/inversify.config.ts b/src/inversify.config.ts
index 6272dd3..623e631 100644
--- a/src/inversify.config.ts
+++ b/src/inversify.config.ts
@@ -7,6 +7,7 @@ import ConfigProvider from './services/config.js';
// Managers
import PlayerManager from './managers/player.js';
+import UpdatingQueueEmbed from './managers/updating-queue-embed.js';
// Helpers
import GetSongs from './services/get-songs.js';
@@ -46,6 +47,7 @@ container.bind<Client>(TYPES.Client).toConstantValue(new Client({intents}));
// Managers
container.bind<PlayerManager>(TYPES.Managers.Player).to(PlayerManager).inSingletonScope();
+container.bind<UpdatingQueueEmbed>(TYPES.Managers.UpdatingQueueEmbed).to(UpdatingQueueEmbed).inSingletonScope();
// Helpers
container.bind<GetSongs>(TYPES.Services.GetSongs).to(GetSongs).inSingletonScope();
diff --git a/src/managers/updating-queue-embed.ts b/src/managers/updating-queue-embed.ts
new file mode 100644
index 0000000..37732de
--- /dev/null
+++ b/src/managers/updating-queue-embed.ts
@@ -0,0 +1,33 @@
+import {inject, injectable} from 'inversify';
+import {TYPES} from '../types.js';
+import PlayerManager from '../managers/player.js';
+import UpdatingQueueEmbed from '../services/updating-queue-embed.js';
+
+@injectable()
+export default class {
+ private readonly embedsByGuild: Map<string, UpdatingQueueEmbed>;
+ private readonly playerManager: PlayerManager;
+
+ constructor(@inject(TYPES.Managers.Player) playerManager: PlayerManager) {
+ this.embedsByGuild = new Map();
+ this.playerManager = playerManager;
+ }
+
+ get(guildId: string): UpdatingQueueEmbed {
+ let embed = this.embedsByGuild.get(guildId);
+
+ if (!embed) {
+ const player = this.playerManager.get(guildId);
+
+ if (!player) {
+ throw new Error('Player does not exist for guild.');
+ }
+
+ embed = new UpdatingQueueEmbed(player);
+
+ this.embedsByGuild.set(guildId, embed);
+ }
+
+ return embed;
+ }
+}
diff --git a/src/services/player.ts b/src/services/player.ts
index 5caace2..26e1e36 100644
--- a/src/services/player.ts
+++ b/src/services/player.ts
@@ -1,5 +1,7 @@
import {VoiceChannel, Snowflake, Client, TextChannel} from 'discord.js';
import {Readable} from 'stream';
+import EventEmitter from 'events';
+import TypedEmitter from 'typed-emitter';
import hasha from 'hasha';
import ytdl from 'ytdl-core';
import {WriteStream} from 'fs-capacitor';
@@ -29,8 +31,11 @@ export enum STATUS {
PAUSED,
}
-export default class {
- public status = STATUS.PAUSED;
+export interface PlayerEvents {
+ statusChange: (oldStatus: STATUS, newStatus: STATUS) => void;
+}
+
+export default class extends (EventEmitter as new () => TypedEmitter<PlayerEvents>) {
public voiceConnection: VoiceConnection | null = null;
private queue: QueuedSong[] = [];
private queuePosition = 0;
@@ -40,11 +45,14 @@ export default class {
private lastSongURL = '';
private positionInSeconds = 0;
+ private internalStatus = STATUS.PAUSED;
private readonly discordClient: Client;
private readonly fileCache: FileCacheProvider;
constructor(client: Client, fileCache: FileCacheProvider) {
+ // eslint-disable-next-line constructor-super
+ super();
this.discordClient = client;
this.fileCache = fileCache;
}
@@ -203,8 +211,12 @@ export default class {
}
}
+ canGoForward(skip: number) {
+ return (this.queuePosition + skip - 1) < this.queue.length;
+ }
+
manualForward(skip: number): void {
- if ((this.queuePosition + skip - 1) < this.queue.length) {
+ if (this.canGoForward(skip)) {
this.queuePosition += skip;
this.positionInSeconds = 0;
this.stopTrackingPosition();
@@ -213,8 +225,12 @@ export default class {
}
}
+ canGoBack() {
+ return this.queuePosition - 1 >= 0;
+ }
+
async back(): Promise<void> {
- if (this.queuePosition - 1 >= 0) {
+ if (this.canGoBack()) {
this.queuePosition--;
this.positionInSeconds = 0;
this.stopTrackingPosition();
@@ -290,6 +306,17 @@ export default class {
return this.queueSize() === 0;
}
+ get status() {
+ return this.internalStatus;
+ }
+
+ set status(newStatus: STATUS) {
+ const previousStatus = this.internalStatus;
+ this.internalStatus = newStatus;
+
+ this.emit('statusChange', previousStatus, newStatus);
+ }
+
private getHashForCache(url: string): string {
return hasha(url);
}
diff --git a/src/services/updating-queue-embed.ts b/src/services/updating-queue-embed.ts
new file mode 100644
index 0000000..31d58ff
--- /dev/null
+++ b/src/services/updating-queue-embed.ts
@@ -0,0 +1,196 @@
+import {CommandInteraction, MessageActionRow, MessageButton, MessageEmbed} from 'discord.js';
+import getYouTubeID from 'get-youtube-id';
+import getProgressBar from '../utils/get-progress-bar.js';
+import {prettyTime} from '../utils/time.js';
+import Player, {STATUS} from './player.js';
+
+const PAGE_SIZE = 10;
+
+const REFRESH_INTERVAL_MS = 5 * 1000;
+
+export enum BUTTON_IDS {
+ PAGE_BACK = 'page-back',
+ PAGE_FORWARD = 'page-forward',
+ TRACK_BACK = 'track-back',
+ TRACK_FORWARD = 'track-forward',
+ PAUSE = 'pause',
+ PLAY = 'play',
+}
+
+export default class {
+ private readonly player: Player;
+ private interaction?: CommandInteraction;
+
+ // 1-indexed
+ private currentPage = 1;
+
+ private refreshTimeout?: NodeJS.Timeout;
+
+ constructor(player: Player) {
+ this.player = player;
+
+ this.addEventHandlers();
+ }
+
+ /**
+ * Creates & replies with a new embed from the given interaction.
+ * Starts updating the embed at a regular interval.
+ * Can be called multiple times within the lifecycle of this class.
+ * Calling this method will make it forgot the previous interaction & reply.
+ * @param interaction
+ */
+ async createFromInteraction(interaction: CommandInteraction) {
+ this.interaction = interaction;
+ this.currentPage = 1;
+
+ await interaction.reply({
+ embeds: [this.buildEmbed()],
+ components: this.buildButtons(this.player),
+ });
+
+ if (!this.refreshTimeout) {
+ this.refreshTimeout = setInterval(async () => this.update(), REFRESH_INTERVAL_MS);
+ }
+ }
+
+ async update(shouldResetPage = false) {
+ if (shouldResetPage) {
+ this.currentPage = 1;
+ }
+
+ await this.interaction?.editReply({
+ embeds: [this.buildEmbed()],
+ components: this.buildButtons(this.player),
+ });
+ }
+
+ async pageBack() {
+ if (this.currentPage > 1) {
+ this.currentPage--;
+ }
+
+ await this.update();
+ }
+
+ async pageForward() {
+ if (this.currentPage < this.getMaxPage()) {
+ this.currentPage++;
+ }
+
+ await this.update();
+ }
+
+ private buildButtons(player: Player): MessageActionRow[] {
+ const queuePageControls = new MessageActionRow()
+ .addComponents(
+ new MessageButton()
+ .setCustomId(BUTTON_IDS.PAGE_BACK)
+ .setStyle('SECONDARY')
+ .setDisabled(this.currentPage === 1)
+ .setEmoji('⬅️'),
+
+ new MessageButton()
+ .setCustomId(BUTTON_IDS.PAGE_FORWARD)
+ .setStyle('SECONDARY')
+ .setDisabled(this.currentPage >= this.getMaxPage())
+ .setEmoji('➡️'),
+ );
+
+ const components = [];
+
+ components.push(
+ new MessageButton()
+ .setCustomId(BUTTON_IDS.TRACK_BACK)
+ .setStyle('PRIMARY')
+ .setDisabled(!player.canGoBack())
+ .setEmoji('⏮'));
+
+ if (player.status === STATUS.PLAYING) {
+ components.push(
+ new MessageButton()
+ .setCustomId(BUTTON_IDS.PAUSE)
+ .setStyle('PRIMARY')
+ .setDisabled(!player.getCurrent())
+ .setEmoji('⏸️'));
+ } else {
+ components.push(
+ new MessageButton()
+ .setCustomId(BUTTON_IDS.PLAY)
+ .setStyle('PRIMARY')
+ .setDisabled(!player.getCurrent())
+ .setEmoji('▶️'));
+ }
+
+ components.push(
+ new MessageButton()
+ .setCustomId(BUTTON_IDS.TRACK_FORWARD)
+ .setStyle('PRIMARY')
+ .setDisabled(!player.canGoForward(1))
+ .setEmoji('⏭'),
+ );
+
+ const playerControls = new MessageActionRow().addComponents(components);
+
+ return [queuePageControls, playerControls];
+ }
+
+ /**
+ * Generates an embed for the current page of the queue.
+ * @returns MessageEmbed
+ */
+ private buildEmbed() {
+ const currentlyPlaying = this.player.getCurrent();
+
+ if (!currentlyPlaying) {
+ throw new Error('queue is empty');
+ }
+
+ const queueSize = this.player.queueSize();
+
+ if (this.currentPage > this.getMaxPage()) {
+ throw new Error('the queue isn\'t that big');
+ }
+
+ const embed = new MessageEmbed();
+
+ embed.setTitle(currentlyPlaying.title);
+ embed.setURL(`https://www.youtube.com/watch?v=${currentlyPlaying.url.length === 11 ? currentlyPlaying.url : getYouTubeID(currentlyPlaying.url) ?? ''}`);
+
+ let description = getProgressBar(20, this.player.getPosition() / currentlyPlaying.length);
+ description += ' ';
+ description += `\`[${prettyTime(this.player.getPosition())}/${currentlyPlaying.isLive ? 'live' : prettyTime(currentlyPlaying.length)}]\``;
+ description += ' 🔉';
+ description += this.player.isQueueEmpty() ? '' : '\n\n**Next up:**';
+
+ embed.setDescription(description);
+
+ let footer = `Source: ${currentlyPlaying.artist}`;
+
+ if (currentlyPlaying.playlist) {
+ footer += ` (${currentlyPlaying.playlist.title})`;
+ }
+
+ embed.setFooter(footer);
+
+ const queuePageBegin = (this.currentPage - 1) * PAGE_SIZE;
+ const queuePageEnd = queuePageBegin + PAGE_SIZE;
+
+ this.player.getQueue().slice(queuePageBegin, queuePageEnd).forEach((song, i) => {
+ embed.addField(`${(i + 1 + queuePageBegin).toString()}/${queueSize.toString()}`, song.title, false);
+ });
+
+ embed.addField('Page', `${this.currentPage} out of ${this.getMaxPage()}`, false);
+
+ return embed;
+ }
+
+ private getMaxPage() {
+ return Math.ceil((this.player.queueSize() + 1) / PAGE_SIZE);
+ }
+
+ private addEventHandlers() {
+ this.player.on('statusChange', async () => this.update(true));
+
+ // TODO: also update on other player events
+ }
+}
diff --git a/src/types.ts b/src/types.ts
index e6edd14..19c734d 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -8,6 +8,7 @@ export const TYPES = {
ThirdParty: Symbol('ThirdParty'),
Managers: {
Player: Symbol('PlayerManager'),
+ UpdatingQueueEmbed: Symbol('UpdatingQueueEmbed'),
},
Services: {
GetSongs: Symbol('GetSongs'),
diff --git a/yarn.lock b/yarn.lock
index d9fa000..ffbdcbc 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3496,6 +3496,11 @@ type-fest@^2.5.4:
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-2.5.4.tgz#1613bf29a172ff1c66c29325466af9096fe505b5"
integrity sha512-zyPomVvb6u7+gJ/GPYUH6/nLDNiTtVOqXVUHtxFv5PmZQh6skgfeRtFYzWC01T5KeNWNIx5/0P111rKFLlkFvA==
+typed-emitter@^1.4.0:
+ version "1.4.0"
+ resolved "https://registry.yarnpkg.com/typed-emitter/-/typed-emitter-1.4.0.tgz#38c6bf1224e764906bb20cb0b458fa914100607c"
+ integrity sha512-weBmoo3HhpKGgLBOYwe8EB31CzDFuaK7CCL+axXhUYhn4jo6DSkHnbefboCF5i4DQ2aMFe0C/FdTWcPdObgHyg==
+
typedarray-to-buffer@^3.1.5:
version "3.1.5"
resolved "https://registry.yarnpkg.com/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz#a97ee7a9ff42691b9f783ff1bc5112fe3fca9080"