diff --git a/package.json b/package.json index 0472ca3..580f85c 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "dotenv": "^8.2.0", "fluent-ffmpeg": "^2.1.2", "fs-capacitor": "^6.1.0", + "get-youtube-id": "^1.0.1", "got": "^10.6.0", "hasha": "^5.2.0", "inversify": "^5.0.1", diff --git a/src/bot.ts b/src/bot.ts index 434a514..4ddbfcb 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -90,8 +90,8 @@ export default class { await handler.execute(msg, args); } catch (error) { - console.error(error); - await msg.channel.send(errorMsg('¯\\_(ツ)_/¯')); + debug(error); + await msg.channel.send(errorMsg(error.message.toLowerCase())); } }); diff --git a/src/commands/clear.ts b/src/commands/clear.ts index a585af4..f9b1853 100644 --- a/src/commands/clear.ts +++ b/src/commands/clear.ts @@ -1,7 +1,7 @@ import {Message} from 'discord.js'; import {TYPES} from '../types'; import {inject, injectable} from 'inversify'; -import QueueManager from '../managers/queue'; +import PlayerManager from '../managers/player'; import Command from '.'; @injectable() @@ -14,14 +14,14 @@ export default class implements Command { public requiresVC = true; - private readonly queueManager: QueueManager; + private readonly playerManager: PlayerManager; - constructor(@inject(TYPES.Managers.Queue) queueManager: QueueManager) { - this.queueManager = queueManager; + constructor(@inject(TYPES.Managers.Player) playerManager: PlayerManager) { + this.playerManager = playerManager; } public async execute(msg: Message, _: string []): Promise { - this.queueManager.get(msg.guild!.id).clear(); + this.playerManager.get(msg.guild!.id).clear(); await msg.channel.send('clearer than a field after a fresh harvest'); } diff --git a/src/commands/fseek.ts b/src/commands/fseek.ts index 584c581..fe8ee93 100644 --- a/src/commands/fseek.ts +++ b/src/commands/fseek.ts @@ -2,7 +2,6 @@ import {Message, TextChannel} from 'discord.js'; import {TYPES} from '../types'; import {inject, injectable} from 'inversify'; import PlayerManager from '../managers/player'; -import QueueManager from '../managers/queue'; import LoadingMessage from '../utils/loading-message'; import errorMsg from '../utils/error-msg'; import Command from '.'; @@ -18,17 +17,15 @@ export default class implements Command { public requiresVC = true; private readonly playerManager: PlayerManager; - private readonly queueManager: QueueManager; - constructor(@inject(TYPES.Managers.Player) playerManager: PlayerManager, @inject(TYPES.Managers.Queue) queueManager: QueueManager) { + constructor(@inject(TYPES.Managers.Player) playerManager: PlayerManager) { this.playerManager = playerManager; - this.queueManager = queueManager; } public async execute(msg: Message, args: string []): Promise { - const queue = this.queueManager.get(msg.guild!.id); + const player = this.playerManager.get(msg.guild!.id); - const currentSong = queue.getCurrent(); + const currentSong = player.getCurrent(); if (!currentSong) { await msg.channel.send(errorMsg('nothing is playing')); @@ -42,7 +39,7 @@ export default class implements Command { const seekTime = parseInt(args[0], 10); - if (seekTime + this.playerManager.get(msg.guild!.id).getPosition() > currentSong.length) { + if (seekTime + player.getPosition() > currentSong.length) { await msg.channel.send(errorMsg('can\'t seek past the end of the song')); return; } @@ -52,7 +49,7 @@ export default class implements Command { await loading.start(); try { - await this.playerManager.get(msg.guild!.id).forwardSeek(seekTime); + await player.forwardSeek(seekTime); await loading.stop(); } catch (error) { diff --git a/src/commands/play.ts b/src/commands/play.ts index 30f6314..10db7d2 100644 --- a/src/commands/play.ts +++ b/src/commands/play.ts @@ -2,9 +2,8 @@ import {TextChannel, Message} from 'discord.js'; import {URL} from 'url'; import {TYPES} from '../types'; import {inject, injectable} from 'inversify'; -import {QueuedSong} from '../services/queue'; +import {QueuedSong} from '../services/player'; import {STATUS} from '../services/player'; -import QueueManager from '../managers/queue'; import PlayerManager from '../managers/player'; import {getMostPopularVoiceChannel} from '../utils/channels'; import LoadingMessage from '../utils/loading-message'; @@ -28,12 +27,10 @@ export default class implements Command { public requiresVC = true; - private readonly queueManager: QueueManager; private readonly playerManager: PlayerManager; private readonly getSongs: GetSongs; - constructor(@inject(TYPES.Managers.Queue) queueManager: QueueManager, @inject(TYPES.Managers.Player) playerManager: PlayerManager, @inject(TYPES.Services.GetSongs) getSongs: GetSongs) { - this.queueManager = queueManager; + constructor(@inject(TYPES.Managers.Player) playerManager: PlayerManager, @inject(TYPES.Services.GetSongs) getSongs: GetSongs) { this.playerManager = playerManager; this.getSongs = getSongs; } @@ -44,11 +41,10 @@ export default class implements Command { const res = new LoadingMessage(msg.channel as TextChannel); await res.start(); - const queue = this.queueManager.get(msg.guild!.id); const player = this.playerManager.get(msg.guild!.id); - const queueOldSize = queue.size(); - const wasPlayingSong = queue.getCurrent() !== null; + const queueOldSize = player.queueSize(); + const wasPlayingSong = player.getCurrent() !== null; if (args.length === 0) { if (player.status === STATUS.PLAYING) { @@ -57,7 +53,7 @@ export default class implements Command { } // Must be resuming play - if (queue.get().length === 0 && !queue.getCurrent()) { + if (!wasPlayingSong) { await res.stop(errorMsg('nothing to play')); return; } @@ -101,11 +97,15 @@ export default class implements Command { extraMsg = 'a random sample of 50 songs was taken'; } + if (totalSongs > 50 && nSongsNotFound !== 0) { + extraMsg += ' and '; + } + if (nSongsNotFound !== 0) { if (nSongsNotFound === 1) { - extraMsg += 'and 1 song was not found'; + extraMsg += '1 song was not found'; } else { - extraMsg += `and ${nSongsNotFound.toString()} songs were not found`; + extraMsg += `${nSongsNotFound.toString()} songs were not found`; } } @@ -130,7 +130,7 @@ export default class implements Command { return; } - newSongs.forEach(song => queue.add(song)); + newSongs.forEach(song => player.add(song)); const firstSong = newSongs[0]; diff --git a/src/commands/queue.ts b/src/commands/queue.ts index 1ec2016..67f88a4 100644 --- a/src/commands/queue.ts +++ b/src/commands/queue.ts @@ -1,13 +1,13 @@ import {Message, MessageEmbed} from 'discord.js'; import {TYPES} from '../types'; import {inject, injectable} from 'inversify'; -import QueueManager from '../managers/queue'; import PlayerManager from '../managers/player'; import {STATUS} from '../services/player'; import Command from '.'; import getProgressBar from '../utils/get-progress-bar'; import errorMsg from '../utils/error-msg'; import {prettyTime} from '../utils/time'; +import getYouTubeID from 'get-youtube-id'; const PAGE_SIZE = 10; @@ -19,22 +19,19 @@ export default class implements Command { ['queue', 'shows current queue'] ]; - private readonly queueManager: QueueManager; private readonly playerManager: PlayerManager; - constructor(@inject(TYPES.Managers.Queue) queueManager: QueueManager, @inject(TYPES.Managers.Player) playerManager: PlayerManager) { - this.queueManager = queueManager; + constructor(@inject(TYPES.Managers.Player) playerManager: PlayerManager) { this.playerManager = playerManager; } public async execute(msg: Message, args: string []): Promise { - const queue = this.queueManager.get(msg.guild!.id); const player = this.playerManager.get(msg.guild!.id); - const currentlyPlaying = queue.getCurrent(); + const currentlyPlaying = player.getCurrent(); if (currentlyPlaying) { - const queueSize = queue.size(); + const queueSize = player.queueSize(); const queuePage = args[0] ? parseInt(args[0], 10) : 1; if (queuePage * PAGE_SIZE > queueSize && queuePage > Math.ceil((queueSize + 1) / PAGE_SIZE)) { @@ -45,23 +42,30 @@ export default class implements Command { const embed = new MessageEmbed(); embed.setTitle(currentlyPlaying.title); - embed.setURL(`https://www.youtube.com/watch?v=${currentlyPlaying.url}`); - embed.setFooter(`Source: ${currentlyPlaying.artist}`); + 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())}/${prettyTime(currentlyPlaying.length)}]\``; + description += `\`[${prettyTime(player.getPosition())}/${currentlyPlaying.isLive ? 'live' : prettyTime(currentlyPlaying.length)}]\``; description += ' 🔉'; - description += queue.isEmpty() ? '' : '\n\n**Next up:**'; + 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; - queue.get().slice(queuePageBegin, queuePageEnd).forEach((song, i) => { + player.getQueue().slice(queuePageBegin, queuePageEnd).forEach((song, i) => { embed.addField(`${(i + 1 + queuePageBegin).toString()}/${queueSize.toString()}`, song.title, false); }); diff --git a/src/commands/seek.ts b/src/commands/seek.ts index 24fde7c..89e174e 100644 --- a/src/commands/seek.ts +++ b/src/commands/seek.ts @@ -2,7 +2,6 @@ import {Message, TextChannel} from 'discord.js'; import {TYPES} from '../types'; import {inject, injectable} from 'inversify'; import PlayerManager from '../managers/player'; -import QueueManager from '../managers/queue'; import LoadingMessage from '../utils/loading-message'; import errorMsg from '../utils/error-msg'; import Command from '.'; @@ -20,17 +19,15 @@ export default class implements Command { public requiresVC = true; private readonly playerManager: PlayerManager; - private readonly queueManager: QueueManager; - constructor(@inject(TYPES.Managers.Player) playerManager: PlayerManager, @inject(TYPES.Managers.Queue) queueManager: QueueManager) { + constructor(@inject(TYPES.Managers.Player) playerManager: PlayerManager) { this.playerManager = playerManager; - this.queueManager = queueManager; } public async execute(msg: Message, args: string []): Promise { - const queue = this.queueManager.get(msg.guild!.id); + const player = this.playerManager.get(msg.guild!.id); - const currentSong = queue.getCurrent(); + const currentSong = player.getCurrent(); if (!currentSong) { await msg.channel.send(errorMsg('nothing is playing')); @@ -69,7 +66,7 @@ export default class implements Command { await loading.start(); try { - await this.playerManager.get(msg.guild!.id).seek(seekTime); + await player.seek(seekTime); await loading.stop(); } catch (error) { diff --git a/src/commands/shuffle.ts b/src/commands/shuffle.ts index a100b9b..c9a007d 100644 --- a/src/commands/shuffle.ts +++ b/src/commands/shuffle.ts @@ -1,7 +1,7 @@ import {Message} from 'discord.js'; import {TYPES} from '../types'; import {inject, injectable} from 'inversify'; -import QueueManager from '../managers/queue'; +import PlayerManager from '../managers/player'; import errorMsg from '../utils/error-msg'; import Command from '.'; @@ -15,23 +15,22 @@ export default class implements Command { public requiresVC = true; - private readonly queueManager: QueueManager; + private readonly playerManager: PlayerManager; - constructor(@inject(TYPES.Managers.Queue) queueManager: QueueManager) { - this.queueManager = queueManager; + constructor(@inject(TYPES.Managers.Player) playerManager: PlayerManager) { + this.playerManager = playerManager; } public async execute(msg: Message, _: string []): Promise { - const queue = this.queueManager.get(msg.guild!.id).get(); + const player = this.playerManager.get(msg.guild!.id); - if (queue.length <= 2) { + if (player.isQueueEmpty()) { await msg.channel.send(errorMsg('not enough songs to shuffle')); return; } - this.queueManager.get(msg.guild!.id).shuffle(); + player.shuffle(); - // TODO: better response await msg.channel.send('shuffled'); } } diff --git a/src/commands/skip.ts b/src/commands/skip.ts index 1eb23a0..72b3f46 100644 --- a/src/commands/skip.ts +++ b/src/commands/skip.ts @@ -1,9 +1,10 @@ -import {Message} from 'discord.js'; +import {Message, TextChannel} from 'discord.js'; import {TYPES} from '../types'; import {inject, injectable} from 'inversify'; import PlayerManager from '../managers/player'; -import QueueManager from '../managers/queue'; import Command from '.'; +import LoadingMessage from '../utils/loading-message'; +import errorMsg from '../utils/error-msg'; @injectable() export default class implements Command { @@ -15,29 +16,24 @@ export default class implements Command { public requiresVC = true; - private readonly queueManager: QueueManager; private readonly playerManager: PlayerManager; - constructor(@inject(TYPES.Managers.Queue) queueManager: QueueManager, @inject(TYPES.Managers.Player) playerManager: PlayerManager) { - this.queueManager = queueManager; + constructor(@inject(TYPES.Managers.Player) playerManager: PlayerManager) { this.playerManager = playerManager; } public async execute(msg: Message, _: string []): Promise { - const queue = this.queueManager.get(msg.guild!.id); const player = this.playerManager.get(msg.guild!.id); + const loader = new LoadingMessage(msg.channel as TextChannel); + try { - queue.forward(); - player.resetPosition(); + await loader.start(); + await player.forward(); - if (queue.isEmpty() && !queue.getCurrent()) { - player.disconnect(); - } - - await msg.channel.send('keep \'er movin\''); + await loader.stop('keep \'er movin\''); } catch (_) { - await msg.channel.send('no song to skip to'); + await loader.stop(errorMsg('no song to skip to')); } } } diff --git a/src/commands/unskip.ts b/src/commands/unskip.ts index a48d819..60147b0 100644 --- a/src/commands/unskip.ts +++ b/src/commands/unskip.ts @@ -2,7 +2,6 @@ import {Message} from 'discord.js'; import {TYPES} from '../types'; import {inject, injectable} from 'inversify'; import PlayerManager from '../managers/player'; -import QueueManager from '../managers/queue'; import errorMsg from '../utils/error-msg'; import Command from '.'; @@ -16,21 +15,17 @@ export default class implements Command { public requiresVC = true; - private readonly queueManager: QueueManager; private readonly playerManager: PlayerManager; - constructor(@inject(TYPES.Managers.Queue) queueManager: QueueManager, @inject(TYPES.Managers.Player) playerManager: PlayerManager) { - this.queueManager = queueManager; + constructor(@inject(TYPES.Managers.Player) playerManager: PlayerManager) { this.playerManager = playerManager; } public async execute(msg: Message, _: string []): Promise { - const queue = this.queueManager.get(msg.guild!.id); const player = this.playerManager.get(msg.guild!.id); try { - queue.back(); - player.resetPosition(); + await player.back(); await msg.channel.send('back \'er up\''); } catch (_) { diff --git a/src/inversify.config.ts b/src/inversify.config.ts index 2e53f14..6c1553e 100644 --- a/src/inversify.config.ts +++ b/src/inversify.config.ts @@ -17,7 +17,6 @@ import { // Managers import PlayerManager from './managers/player'; -import QueueManager from './managers/queue'; // Helpers import GetSongs from './services/get-songs'; @@ -47,7 +46,6 @@ container.bind(TYPES.Client).toConstantValue(new Client()); // Managers container.bind(TYPES.Managers.Player).to(PlayerManager).inSingletonScope(); -container.bind(TYPES.Managers.Queue).to(QueueManager).inSingletonScope(); // Helpers container.bind(TYPES.Services.GetSongs).to(GetSongs).inSingletonScope(); diff --git a/src/managers/player.ts b/src/managers/player.ts index e8bdf76..b05d506 100644 --- a/src/managers/player.ts +++ b/src/managers/player.ts @@ -1,25 +1,22 @@ import {inject, injectable} from 'inversify'; import {TYPES} from '../types'; import Player from '../services/player'; -import QueueManager from './queue'; @injectable() export default class { private readonly guildPlayers: Map; private readonly cacheDir: string; - private readonly queueManager: QueueManager; - constructor(@inject(TYPES.Config.CACHE_DIR) cacheDir: string, @inject(TYPES.Managers.Queue) queueManager: QueueManager) { + constructor(@inject(TYPES.Config.CACHE_DIR) cacheDir: string) { this.guildPlayers = new Map(); this.cacheDir = cacheDir; - this.queueManager = queueManager; } get(guildId: string): Player { let player = this.guildPlayers.get(guildId); if (!player) { - player = new Player(this.queueManager.get(guildId), this.cacheDir); + player = new Player(this.cacheDir); this.guildPlayers.set(guildId, player); } diff --git a/src/managers/queue.ts b/src/managers/queue.ts deleted file mode 100644 index 6c12232..0000000 --- a/src/managers/queue.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {injectable} from 'inversify'; -import Queue from '../services/queue'; - -@injectable() -export default class { - private readonly guildQueues: Map; - - constructor() { - this.guildQueues = new Map(); - } - - get(guildId: string): Queue { - let queue = this.guildQueues.get(guildId); - - if (!queue) { - queue = new Queue(); - - this.guildQueues.set(guildId, queue); - } - - return queue; - } -} diff --git a/src/services/get-songs.ts b/src/services/get-songs.ts index ed1d125..4e8d955 100644 --- a/src/services/get-songs.ts +++ b/src/services/get-songs.ts @@ -8,8 +8,9 @@ import ytsr from 'ytsr'; import YouTube from 'youtube.ts'; import pLimit from 'p-limit'; import uniqueRandomArray from 'unique-random-array'; -import {QueuedSong, QueuedPlaylist} from '../services/queue'; +import {QueuedSong, QueuedPlaylist} from '../services/player'; import {TYPES} from '../types'; +import {parseTime} from '../utils/time'; @injectable() export default class { @@ -186,7 +187,7 @@ export default class { return { title: video.title, artist: track.artists[0].name, - length: track.duration_ms / 1000, + length: parseTime(video.duration), url: video.link, playlist, isLive: video.live diff --git a/src/services/natural-language-commands.ts b/src/services/natural-language-commands.ts index 2fe610b..7171a87 100644 --- a/src/services/natural-language-commands.ts +++ b/src/services/natural-language-commands.ts @@ -2,17 +2,14 @@ import {inject, injectable} from 'inversify'; import {Message} from 'discord.js'; import {TYPES} from '../types'; import PlayerManager from '../managers/player'; -import QueueManager from '../managers/queue'; import {getMostPopularVoiceChannel} from '../utils/channels'; @injectable() export default class { private readonly playerManager: PlayerManager; - private readonly queueManager: QueueManager; - constructor(@inject(TYPES.Managers.Player) playerManager: PlayerManager, @inject(TYPES.Managers.Queue) queueManager: QueueManager) { + constructor(@inject(TYPES.Managers.Player) playerManager: PlayerManager) { this.playerManager = playerManager; - this.queueManager = queueManager; } async execute(msg: Message): Promise { @@ -24,7 +21,6 @@ export default class { } if (msg.content.includes('packers')) { - const queue = this.queueManager.get(msg.guild!.id); const player = this.playerManager.get(msg.guild!.id); const [channel, n] = getMostPopularVoiceChannel(msg.guild!); @@ -39,14 +35,15 @@ export default class { await player.connect(channel); } - const isPlaying = queue.getCurrent() !== null; + const isPlaying = player.getCurrent() !== null; let oldPosition = 0; - queue.add({title: 'GO PACKERS!', artist: 'Unknown', url: 'https://www.youtube.com/watch?v=qkdtID7mY3E', length: 204, playlist: null, isLive: false}); + player.add({title: 'GO PACKERS!', artist: 'Unknown', url: 'https://www.youtube.com/watch?v=qkdtID7mY3E', length: 204, playlist: null, isLive: false}, {immediate: true}); if (isPlaying) { oldPosition = player.getPosition(); - queue.forward(); + + await player.forward(); } await player.seek(8); @@ -54,10 +51,10 @@ export default class { return new Promise((resolve, reject) => { try { setTimeout(async () => { - queue.removeCurrent(); + player.removeCurrent(); if (isPlaying) { - queue.back(); + await player.back(); await player.seek(oldPosition); } else { player.disconnect(); diff --git a/src/services/player.ts b/src/services/player.ts index 0fd7227..1f24d93 100644 --- a/src/services/player.ts +++ b/src/services/player.ts @@ -6,7 +6,21 @@ import hasha from 'hasha'; import ytdl from 'ytdl-core'; import {WriteStream} from 'fs-capacitor'; import ffmpeg from 'fluent-ffmpeg'; -import Queue, {QueuedSong} from './queue'; +import shuffle from 'array-shuffle'; + +export interface QueuedPlaylist { + title: string; + source: string; +} + +export interface QueuedSong { + title: string; + artist: string; + url: string; + length: number; + playlist: QueuedPlaylist | null; + isLive: boolean; +} export enum STATUS { PLAYING, @@ -16,7 +30,8 @@ export enum STATUS { export default class { public status = STATUS.PAUSED; public voiceConnection: VoiceConnection | null = null; - private readonly queue: Queue; + private queue: QueuedSong[] = []; + private queuePosition = 0; private readonly cacheDir: string; private dispatcher: StreamDispatcher | null = null; private nowPlaying: QueuedSong | null = null; @@ -25,8 +40,7 @@ export default class { private positionInSeconds = 0; - constructor(queue: Queue, cacheDir: string) { - this.queue = queue; + constructor(cacheDir: string) { this.cacheDir = cacheDir; } @@ -58,7 +72,7 @@ export default class { throw new Error('Not connected to a voice channel.'); } - const currentSong = this.queue.getCurrent(); + const currentSong = this.getCurrent(); if (!currentSong) { throw new Error('No song currently playing'); @@ -90,14 +104,14 @@ export default class { throw new Error('Not connected to a voice channel.'); } - const currentSong = this.queue.getCurrent(); + const currentSong = this.getCurrent(); if (!currentSong) { throw new Error('Queue empty.'); } // Resume from paused state - if (this.status === STATUS.PAUSED && this.getPosition() !== 0 && currentSong.url === this.nowPlaying?.url) { + if (this.status === STATUS.PAUSED && currentSong.url === this.nowPlaying?.url) { if (this.dispatcher) { this.dispatcher.resume(); this.status = STATUS.PLAYING; @@ -109,20 +123,25 @@ export default class { return this.seek(this.getPosition()); } - const stream = await this.getStream(currentSong.url); - this.dispatcher = this.voiceConnection.play(stream, {type: 'webm/opus'}); + try { + const stream = await this.getStream(currentSong.url); + this.dispatcher = this.voiceConnection.play(stream, {type: 'webm/opus'}); - this.attachListeners(); + this.attachListeners(); - this.status = STATUS.PLAYING; - this.nowPlaying = currentSong; + this.status = STATUS.PLAYING; + this.nowPlaying = currentSong; - if (currentSong.url === this.lastSongURL) { - this.startTrackingPosition(); - } else { - // Reset position counter - this.startTrackingPosition(0); - this.lastSongURL = currentSong.url; + if (currentSong.url === this.lastSongURL) { + this.startTrackingPosition(); + } else { + // Reset position counter + this.startTrackingPosition(0); + this.lastSongURL = currentSong.url; + } + } catch (error) { + this.removeCurrent(); + throw error; } } @@ -140,8 +159,103 @@ export default class { this.stopTrackingPosition(); } - resetPosition(): void { - this.positionInSeconds = 0; + async forward(): Promise { + if (this.queuePosition < this.queueSize() + 1) { + this.queuePosition++; + + try { + if (this.getCurrent() && this.status !== STATUS.PAUSED) { + await this.play(); + } else { + this.status = STATUS.PAUSED; + this.disconnect(); + } + } catch (error) { + this.queuePosition--; + throw error; + } + } else { + throw new Error('No songs in queue to forward to.'); + } + } + + async back(): Promise { + if (this.queuePosition - 1 >= 0) { + this.queuePosition--; + this.positionInSeconds = 0; + + if (this.status !== STATUS.PAUSED) { + await this.play(); + } + } else { + throw new Error('No songs in queue to go back to.'); + } + } + + getCurrent(): QueuedSong | null { + if (this.queue[this.queuePosition]) { + return this.queue[this.queuePosition]; + } + + return null; + } + + getQueue(): QueuedSong[] { + return this.queue.slice(this.queuePosition + 1); + } + + add(song: QueuedSong, {immediate = false} = {}): void { + if (song.playlist) { + // Add to end of queue + this.queue.push(song); + } else { + // Not from playlist, add immediately + let insertAt = this.queuePosition + 1; + + if (!immediate) { + // Loop until playlist song + this.queue.some(song => { + if (song.playlist) { + return true; + } + + insertAt++; + return false; + }); + } + + this.queue = [...this.queue.slice(0, insertAt), song, ...this.queue.slice(insertAt)]; + } + } + + shuffle(): void { + this.queue = [...this.queue.slice(0, this.queuePosition + 1), ...shuffle(this.queue.slice(this.queuePosition + 1))]; + } + + clear(): void { + const newQueue = []; + + // Don't clear curently playing song + const current = this.getCurrent(); + + if (current) { + newQueue.push(current); + } + + this.queuePosition = 0; + this.queue = newQueue; + } + + removeCurrent(): void { + this.queue = [...this.queue.slice(0, this.queuePosition), ...this.queue.slice(this.queuePosition + 1)]; + } + + queueSize(): number { + return this.getQueue().length; + } + + isQueueEmpty(): boolean { + return this.queueSize() === 0; } private getCachedPath(url: string): string { @@ -271,30 +385,23 @@ export default class { return; } - this.voiceConnection.on('disconnect', () => { - this.disconnect(false); - }); + this.voiceConnection.on('disconnect', this.onVoiceConnectionDisconnect.bind(this)); if (!this.dispatcher) { return; } - this.dispatcher.on('speaking', async isSpeaking => { - // Automatically advance queued song at end - if (!isSpeaking && this.status === STATUS.PLAYING) { - if (this.queue.size() >= 0) { - this.queue.forward(); + this.dispatcher.on('speaking', this.onVoiceConnectionSpeaking.bind(this)); + } - this.positionInSeconds = 0; + private onVoiceConnectionDisconnect(): void { + this.disconnect(false); + } - if (this.queue.getCurrent()) { - await this.play(); - } else { - this.status = STATUS.PAUSED; - this.disconnect(); - } - } - } - }); + private async onVoiceConnectionSpeaking(isSpeaking: boolean): Promise { + // Automatically advance queued song at end + if (!isSpeaking && this.status === STATUS.PLAYING) { + await this.forward(); + } } } diff --git a/src/services/queue.ts b/src/services/queue.ts deleted file mode 100644 index 70d9a58..0000000 --- a/src/services/queue.ts +++ /dev/null @@ -1,97 +0,0 @@ -import shuffle from 'array-shuffle'; - -export interface QueuedPlaylist { - title: string; - source: string; -} - -export interface QueuedSong { - title: string; - artist: string; - url: string; - length: number; - playlist: QueuedPlaylist | null; - isLive: boolean; -} - -export default class { - private queue: QueuedSong[] = []; - private position = 0; - - forward(): void { - if (this.position < this.size() + 1) { - this.position++; - } else { - throw new Error('No songs in queue to forward to.'); - } - } - - back(): void { - if (this.position - 1 >= 0) { - this.position--; - } else { - throw new Error('No songs in queue to go back to.'); - } - } - - getCurrent(): QueuedSong | null { - if (this.queue[this.position]) { - return this.queue[this.position]; - } - - return null; - } - - get(): QueuedSong[] { - return this.queue.slice(this.position + 1); - } - - add(song: QueuedSong): void { - if (song.playlist) { - // Add to end of queue - this.queue.push(song); - } else { - // Not from playlist, add immediately - let insertAt = this.position; - - // Loop until playlist song - this.queue.some(song => { - if (song.playlist) { - return true; - } - - insertAt++; - return false; - }); - - this.queue = [...this.queue.slice(0, insertAt), song, ...this.queue.slice(insertAt)]; - } - } - - shuffle(): void { - this.queue = [...this.queue.slice(0, this.position), this.queue[this.position], this.queue[0], ...shuffle(this.queue.slice(this.position + 1))]; - } - - clear(): void { - const newQueue = []; - - // Don't clear curently playing song - if (this.queue.length > 0) { - newQueue.push(this.queue[this.position]); - } - - this.queue = newQueue; - } - - removeCurrent(): void { - this.queue = [...this.queue.slice(0, this.position), ...this.queue.slice(this.position + 1)]; - } - - size(): number { - return this.get().length; - } - - isEmpty(): boolean { - return this.get().length === 0; - } -} diff --git a/src/types.ts b/src/types.ts index ee18fb8..7ed076d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -14,8 +14,7 @@ export const TYPES = { Spotify: Symbol('Spotify') }, Managers: { - Player: Symbol('PlayerManager'), - Queue: Symbol('QueueManager') + Player: Symbol('PlayerManager') }, Services: { GetSongs: Symbol('GetSongs'), diff --git a/src/utils/loading-message.ts b/src/utils/loading-message.ts index dd3e9b9..115aff2 100644 --- a/src/utils/loading-message.ts +++ b/src/utils/loading-message.ts @@ -60,10 +60,16 @@ export default class { } async stop(str = 'u betcha'): Promise { + const wasAlreadyStopped = this.isStopped; + this.isStopped = true; if (str) { - await Promise.all([this.msg.reactions.removeAll(), this.msg.edit(str)]); + if (wasAlreadyStopped) { + await this.msg.edit(str); + } else { + await Promise.all([this.msg.reactions.removeAll(), this.msg.edit(str)]); + } } else { await this.msg.reactions.removeAll(); } diff --git a/src/utils/time.ts b/src/utils/time.ts index a54bfa7..734cb00 100644 --- a/src/utils/time.ts +++ b/src/utils/time.ts @@ -13,3 +13,5 @@ export const prettyTime = (seconds: number): string => { return res; }; + +export const parseTime = (str: string): number => str.split(':').reduce((acc, time) => (60 * acc) + parseInt(time, 10), 0); diff --git a/yarn.lock b/yarn.lock index 5aab5c8..dd1c0be 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1160,6 +1160,11 @@ get-stream@^5.0.0, get-stream@^5.1.0: dependencies: pump "^3.0.0" +get-youtube-id@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/get-youtube-id/-/get-youtube-id-1.0.1.tgz#adb6f475e292d98f98ed5bfb530887656193e157" + integrity sha512-5yidLzoLXbtw82a/Wb7LrajkGn29BM6JuLWeHyNfzOGp1weGyW4+7eMz6cP23+etqj27VlOFtq8fFFDMLq/FXQ== + getpass@^0.1.1: version "0.1.7" resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa"