Start migrating queue command

Also: make it persistent & updating, add buttons 
This commit is contained in:
Max Isom 2021-12-15 22:01:54 -05:00
parent e965c02358
commit 0b20cb3982
No known key found for this signature in database
GPG key ID: 25C9B1A7F6798880
11 changed files with 370 additions and 72 deletions

View file

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

View file

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