b68/packages/bot/src/utils/musicQueue.ts

388 lines
13 KiB
TypeScript

import {
AudioPlayer,
AudioPlayerState,
AudioPlayerStatus,
AudioResource,
createAudioPlayer,
entersState,
NoSubscriberBehavior,
VoiceConnection,
VoiceConnectionDisconnectReason,
VoiceConnectionState,
VoiceConnectionStatus,
} from '@discordjs/voice'
import { CommandInteraction, Message, TextChannel, User } from 'discord.js'
import { promisify } from 'node:util'
import { bot } from '../index'
import { QueueOptions } from '../interfaces/QueueOptions'
import config from '../../config/music.json'
import { i18n } from '../utils/i18n'
import { canModifyQueue } from './queue'
import { Song } from './song'
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
public constructor(options: QueueOptions) {
Object.assign(this, options)
this.player = createAudioPlayer({
behaviors: { noSubscriber: NoSubscriberBehavior.Play },
})
this.connection.subscribe(this.player)
const networkStateChangeHandler = (
_oldNetworkState: any,
newNetworkState: any
) => {
const newUdp = Reflect.get(newNetworkState, 'udp')
clearInterval(newUdp?.keepAliveInterval)
}
this.connection.on(
'stateChange' as any,
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 {
console.log('Failed to destroy connection')
}
}
} finally {
this.readyLock = false
}
}
}
)
this.player.on(
'stateChange' as any,
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 {
console.log('Failed to destroy the connection.')
}
}
bot.queues.delete(this.interaction.guild!.id)
!config.PRUNING &&
this.textChannel.send(i18n.__('play.leaveChannel'))
}, config.STAY_TIME * 1000)
}
public async processQueue(): Promise<void> {
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 sendPlayingMessage(newState: any) {
const song = (newState.resource as AudioResource<Song>).metadata
let playingMessage: Message
try {
playingMessage = await this.textChannel.send(
(
newState.resource as AudioResource<Song>
).metadata.startMessage()
)
await playingMessage.react('⏭')
await playingMessage.react('⏯')
await playingMessage.react('🔇')
await playingMessage.react('🔉')
await playingMessage.react('🔊')
await playingMessage.react('🔁')
await playingMessage.react('🔀')
await playingMessage.react('⏹')
} catch (error: any) {
console.error(error)
this.textChannel.send(error.message)
return
}
const filter = (_reaction: any, user: User) =>
user.id !== this.textChannel.client.user!.id
const collector = playingMessage.createReactionCollector({
filter,
time: song.duration > 0 ? song.duration * 1000 : 600000,
})
collector.on('collect', async (reaction, user) => {
if (!this.songs) return
const member = await playingMessage.guild!.members.fetch(user)
switch (reaction.emoji.name) {
case '⏭':
reaction.users.remove(user).catch(console.error)
await this.bot.slashCommandsMap
.get('skip')!
.execute(this.interaction)
break
case '⏯':
reaction.users.remove(user).catch(console.error)
if (this.player.state.status == AudioPlayerStatus.Playing) {
await this.bot.slashCommandsMap
.get('pause')!
.execute(this.interaction)
} else {
await this.bot.slashCommandsMap
.get('resume')!
.execute(this.interaction)
}
break
case '🔇':
reaction.users.remove(user).catch(console.error)
if (!canModifyQueue(member))
return i18n.__('common.errorNotChannel')
this.muted = !this.muted
if (this.muted) {
this.resource.volume?.setVolumeLogarithmic(0)
this.textChannel
.send(i18n.__mf('play.mutedSong', { author: user }))
.catch(console.error)
} else {
this.resource.volume?.setVolumeLogarithmic(
this.volume / 100
)
this.textChannel
.send(
i18n.__mf('play.unmutedSong', { author: user })
)
.catch(console.error)
}
break
case '🔉':
reaction.users.remove(user).catch(console.error)
if (this.volume == 0) return
if (!canModifyQueue(member))
return i18n.__('common.errorNotChannel')
this.volume = Math.max(this.volume - 10, 0)
this.resource.volume?.setVolumeLogarithmic(
this.volume / 100
)
this.textChannel
.send(
i18n.__mf('play.decreasedVolume', {
author: user,
volume: this.volume,
})
)
.catch(console.error)
break
case '🔊':
reaction.users.remove(user).catch(console.error)
if (this.volume == 100) return
if (!canModifyQueue(member))
return i18n.__('common.errorNotChannel')
this.volume = Math.min(this.volume + 10, 100)
this.resource.volume?.setVolumeLogarithmic(
this.volume / 100
)
this.textChannel
.send(
i18n.__mf('play.increasedVolume', {
author: user,
volume: this.volume,
})
)
.catch(console.error)
break
case '🔁':
reaction.users.remove(user).catch(console.error)
await this.bot.slashCommandsMap
.get('loop')!
.execute(this.interaction)
break
case '🔀':
reaction.users.remove(user).catch(console.error)
await this.bot.slashCommandsMap
.get('shuffle')!
.execute(this.interaction)
break
case '⏹':
reaction.users.remove(user).catch(console.error)
await this.bot.slashCommandsMap
.get('stop')!
.execute(this.interaction)
collector.stop()
break
default:
reaction.users.remove(user).catch(console.error)
break
}
})
collector.on('end', () => {
playingMessage.reactions.removeAll().catch(console.error)
if (config.PRUNING) {
setTimeout(() => {
playingMessage.delete().catch()
}, 3000)
}
})
}
}