import { AudioPlayer, AudioPlayerPlayingState, AudioPlayerState, AudioPlayerStatus, AudioResource, createAudioPlayer, entersState, NoSubscriberBehavior, VoiceConnection, VoiceConnectionDisconnectReason, VoiceConnectionState, VoiceConnectionStatus } from "@discordjs/voice"; import { ActionRowBuilder, ButtonBuilder, ButtonInteraction, ButtonStyle, CommandInteraction, GuildMember, Interaction, Message, TextChannel } from "discord.js"; import { promisify } from "node:util"; import { bot } from "../index"; import { QueueOptions } from "../interfaces/QueueOptions"; import { config } from "../utils/config"; import { i18n } from "../utils/i18n"; import { canModifyQueue } from "../utils/queue"; import { Song } from "./Song"; import { safeReply } from "../utils/safeReply"; const wait = promisify(setTimeout); export class MusicQueue { public readonly interaction: CommandInteraction; public readonly connection: VoiceConnection; public readonly player: AudioPlayer; public readonly textChannel: TextChannel; public readonly bot = bot; public resource: AudioResource; public songs: Song[] = []; public volume = config.DEFAULT_VOLUME || 100; public loop = false; public muted = false; public waitTimeout: NodeJS.Timeout | null; private queueLock = false; private readyLock = false; private stopped = false; /** * Constructs a new MusicQueue instance, setting up the audio player, * voice connection, and event listeners to manage voice state changes * and audio playback. It also handles network state changes to ensure * a stable connection for audio streaming. * @param options */ public constructor(options: QueueOptions) { Object.assign(this, options); this.player = createAudioPlayer({ behaviors: { noSubscriber: NoSubscriberBehavior.Play } }); this.connection.subscribe(this.player); const networkStateChangeHandler = ( oldNetworkState: VoiceConnectionState, newNetworkState: VoiceConnectionState ) => { const newUdp = Reflect.get(newNetworkState, "udp"); clearInterval(newUdp?.keepAliveInterval); }; this.connection.on("stateChange", async (oldState: VoiceConnectionState, newState: VoiceConnectionState) => { Reflect.get(oldState, "networking")?.off("stateChange", networkStateChangeHandler); Reflect.get(newState, "networking")?.on("stateChange", networkStateChangeHandler); if (newState.status === VoiceConnectionStatus.Disconnected) { if (newState.reason === VoiceConnectionDisconnectReason.WebSocketClose && newState.closeCode === 4014) { try { this.stop(); } catch (e) { console.log(e); this.stop(); } } else if (this.connection.rejoinAttempts < 5) { await wait((this.connection.rejoinAttempts + 1) * 5_000); this.connection.rejoin(); } else { this.connection.destroy(); } } else if ( !this.readyLock && (newState.status === VoiceConnectionStatus.Connecting || newState.status === VoiceConnectionStatus.Signalling) ) { this.readyLock = true; try { await entersState(this.connection, VoiceConnectionStatus.Ready, 20_000); } catch { if (this.connection.state.status !== VoiceConnectionStatus.Destroyed) { try { this.connection.destroy(); } catch {} } } finally { this.readyLock = false; } } }); this.player.on("stateChange", async (oldState: AudioPlayerState, newState: AudioPlayerState) => { if (oldState.status !== AudioPlayerStatus.Idle && newState.status === AudioPlayerStatus.Idle) { if (this.loop && this.songs.length) { this.songs.push(this.songs.shift()!); } else { this.songs.shift(); if (!this.songs.length) return this.stop(); } if (this.songs.length || this.resource.audioPlayer) this.processQueue(); } else if (oldState.status === AudioPlayerStatus.Buffering && newState.status === AudioPlayerStatus.Playing) { this.sendPlayingMessage(newState); } }); this.player.on("error", (error) => { console.error(error); if (this.loop && this.songs.length) { this.songs.push(this.songs.shift()!); } else { this.songs.shift(); } this.processQueue(); }); } public enqueue(...songs: Song[]) { if (this.waitTimeout !== null) clearTimeout(this.waitTimeout); this.waitTimeout = null; this.stopped = false; this.songs = this.songs.concat(songs); this.processQueue(); } public stop() { if (this.stopped) return; this.stopped = true; this.loop = false; this.songs = []; this.player.stop(); !config.PRUNING && this.textChannel.send(i18n.__("play.queueEnded")).catch(console.error); if (this.waitTimeout !== null) return; this.waitTimeout = setTimeout(() => { if (this.connection.state.status !== VoiceConnectionStatus.Destroyed) { try { this.connection.destroy(); } catch {} } bot.queues.delete(this.interaction.guild!.id); !config.PRUNING && this.textChannel.send(i18n.__("play.leaveChannel")); }, config.STAY_TIME * 1000); } /** * Processes the song queue for playback. This method checks if the queue is locked or if the player * is busy. If not, it proceeds to play the next song in the queue. This method is also responsible * for handling playback errors and retrying song playback when necessary. It ensures that the queue * continues to play smoothly, handling transitions between songs, including loop and stop behaviors. */ public async processQueue(): Promise { if (this.queueLock || this.player.state.status !== AudioPlayerStatus.Idle) { return; } if (!this.songs.length) { return this.stop(); } this.queueLock = true; const next = this.songs[0]; try { const resource = await next.makeResource(); this.resource = resource!; this.player.play(this.resource); this.resource.volume?.setVolumeLogarithmic(this.volume / 100); } catch (error) { console.error(error); return this.processQueue(); } finally { this.queueLock = false; } } private async handleSkip(interaction: ButtonInteraction): Promise { await this.bot.slashCommandsMap.get("skip")!.execute(interaction); } private async handlePlayPause(interaction: ButtonInteraction): Promise { if (this.player.state.status === AudioPlayerStatus.Playing) { await this.bot.slashCommandsMap.get("pause")!.execute(interaction); } else { await this.bot.slashCommandsMap.get("resume")!.execute(interaction); } } private async handleMute(interaction: ButtonInteraction): Promise { if (!canModifyQueue(interaction.member as GuildMember)) return; this.muted = !this.muted; if (this.muted) { this.resource.volume?.setVolumeLogarithmic(0); safeReply(interaction, i18n.__mf("play.mutedSong", { author: interaction.user })).catch(console.error); } else { this.resource.volume?.setVolumeLogarithmic(this.volume / 100); safeReply(interaction, i18n.__mf("play.unmutedSong", { author: interaction.user })).catch(console.error); } } private async handleDecreaseVolume(interaction: ButtonInteraction): Promise { if (this.volume == 0) return; if (!canModifyQueue(interaction.member as GuildMember)) return; this.volume = Math.max(this.volume - 10, 0); this.resource.volume?.setVolumeLogarithmic(this.volume / 100); safeReply(interaction, i18n.__mf("play.decreasedVolume", { author: interaction.user, volume: this.volume })).catch( console.error ); } private async handleIncreaseVolume(interaction: ButtonInteraction): Promise { if (this.volume == 100) return; if (!canModifyQueue(interaction.member as GuildMember)) return; this.volume = Math.min(this.volume + 10, 100); this.resource.volume?.setVolumeLogarithmic(this.volume / 100); safeReply(interaction, i18n.__mf("play.increasedVolume", { author: interaction.user, volume: this.volume })).catch( console.error ); } private async handleLoop(interaction: ButtonInteraction): Promise { await this.bot.slashCommandsMap.get("loop")!.execute(interaction); } private async handleShuffle(interaction: ButtonInteraction): Promise { await this.bot.slashCommandsMap.get("shuffle")!.execute(interaction); } private async handleStop(interaction: ButtonInteraction): Promise { await this.bot.slashCommandsMap.get("stop")!.execute(interaction); } private commandHandlers = new Map([ ["skip", this.handleSkip], ["play_pause", this.handlePlayPause], ["mute", this.handleMute], ["decrease_volume", this.handleDecreaseVolume], ["increase_volume", this.handleIncreaseVolume], ["loop", this.handleLoop], ["shuffle", this.handleShuffle], ["stop", this.handleStop] ]); private createButtonRow() { const firstRow = new ActionRowBuilder().addComponents( new ButtonBuilder().setCustomId("skip").setLabel("⏭").setStyle(ButtonStyle.Secondary), new ButtonBuilder().setCustomId("play_pause").setLabel("⏯").setStyle(ButtonStyle.Secondary), new ButtonBuilder().setCustomId("mute").setLabel("🔇").setStyle(ButtonStyle.Secondary), new ButtonBuilder().setCustomId("decrease_volume").setLabel("🔉").setStyle(ButtonStyle.Secondary), new ButtonBuilder().setCustomId("increase_volume").setLabel("🔊").setStyle(ButtonStyle.Secondary) ); const secondRow = new ActionRowBuilder().addComponents( new ButtonBuilder().setCustomId("loop").setLabel("🔁").setStyle(ButtonStyle.Secondary), new ButtonBuilder().setCustomId("shuffle").setLabel("🔀").setStyle(ButtonStyle.Secondary), new ButtonBuilder().setCustomId("stop").setLabel("⏹").setStyle(ButtonStyle.Secondary) ); return [firstRow, secondRow]; } /** * Sets up a message component collector for the playing message to handle * button interactions. This collector listens for button clicks and dispatches * commands based on the custom ID of the clicked button. It supports functionalities * like skip, stop, play/pause, volume control, and more. The collector is also * responsible for stopping itself when the corresponding song is skipped or stopped, * ensuring that interactions are only valid for the current playing song. */ private async sendPlayingMessage(newState: AudioPlayerPlayingState) { const song = (newState.resource as AudioResource).metadata; let playingMessage: Message; try { playingMessage = await this.textChannel.send({ content: song.startMessage(), components: this.createButtonRow() }); } catch (error: unknown) { console.error(error); if (error instanceof Error) this.textChannel.send(error.message); return; } const filter = (i: Interaction) => i.isButton() && i.message.id === playingMessage.id; const collector = playingMessage.createMessageComponentCollector({ filter, time: song.duration > 0 ? song.duration * 1000 : 60000 }); collector.on("collect", async (interaction) => { if (!interaction.isButton()) return; if (!this.songs) return; const handler = this.commandHandlers.get(interaction.customId); if (["skip", "stop"].includes(interaction.customId)) collector.stop(); if (handler) await handler.call(this, interaction); }); collector.on("end", () => { // Remove the buttons when the song ends playingMessage.edit({ components: [] }).catch(console.error); // Delete the message if pruning is enabled if (config.PRUNING) { setTimeout(() => { playingMessage.delete().catch(); }, 3000); } }); } }