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; + private readonly commandsByName!: Collection; + private readonly commandsByButtonId!: Collection; 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 { @@ -31,7 +33,11 @@ export default class { container.getAll(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 & Pick; - requiresVC?: boolean; + readonly handledButtonIds?: readonly string[]; + readonly requiresVC?: boolean; executeFromInteraction?: (interaction: CommandInteraction) => Promise; + handleButtonInteraction?: (interaction: ButtonInteraction) => Promise; } 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 { - const player = this.playerManager.get(msg.guild!.id); + public async executeFromInteraction(interaction: CommandInteraction) { + const embed = this.updatingQueueEmbedManager.get(interaction.guild!.id); - const currentlyPlaying = player.getCurrent(); + await embed.createFromInteraction(interaction); + } - if (currentlyPlaying) { - const queueSize = player.queueSize(); - const queuePage = args[0] ? parseInt(args[0], 10) : 1; + public async handleButtonInteraction(interaction: ButtonInteraction) { + const player = this.playerManager.get(interaction.guild!.id); + const embed = this.updatingQueueEmbedManager.get(interaction.guild!.id); - const maxQueuePage = Math.ceil((queueSize + 1) / PAGE_SIZE); + const buttonId = interaction.customId as keyof typeof this.handledButtonIds; - if (queuePage > maxQueuePage) { - await msg.channel.send(errorMsg('the queue isn\'t that big')); - return; + // 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(); + + try { + switch (buttonId) { + case BUTTON_IDS.TRACK_BACK: + await player.back(); + break; + + case BUTTON_IDS.TRACK_FORWARD: + await player.forward(1); + break; + + case BUTTON_IDS.PAUSE: + player.pause(); + break; + + case BUTTON_IDS.PLAY: + await player.play(); + break; + + case BUTTON_IDS.PAGE_BACK: + await embed.pageBack(); + break; + + case BUTTON_IDS.PAGE_FORWARD: + await embed.pageForward(); + break; + + default: + throw new Error('unknown customId'); } + } catch (error: unknown) { + await deferedUpdatePromise; - 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 = 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:**'; - - embed.setDescription(description); - - let footer = `Source: ${currentlyPlaying.artist}`; - - if (currentlyPlaying.playlist) { - footer += ` (${currentlyPlaying.playlist.title})`; - } - - embed.setFooter(footer); - - const queuePageBegin = (queuePage - 1) * PAGE_SIZE; - const queuePageEnd = queuePageBegin + PAGE_SIZE; - - player.getQueue().slice(queuePageBegin, queuePageEnd).forEach((song, i) => { - embed.addField(`${(i + 1 + queuePageBegin).toString()}/${queueSize.toString()}`, song.title, false); - }); - - embed.addField('Page', `${queuePage} out of ${maxQueuePage}`, false); - - 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(TYPES.Client).toConstantValue(new Client({intents})); // Managers container.bind(TYPES.Managers.Player).to(PlayerManager).inSingletonScope(); +container.bind(TYPES.Managers.UpdatingQueueEmbed).to(UpdatingQueueEmbed).inSingletonScope(); // Helpers container.bind(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; + 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) { 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 { - 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"