diff options
Diffstat (limited to 'src/handlers')
| -rw-r--r-- | src/handlers/controlCommands.js | 97 | ||||
| -rw-r--r-- | src/handlers/playCommand.js | 271 | ||||
| -rw-r--r-- | src/handlers/queueCommand.js | 38 | ||||
| -rw-r--r-- | src/handlers/seekCommand.js | 51 | ||||
| -rw-r--r-- | src/handlers/volumeCommand.js | 28 |
5 files changed, 485 insertions, 0 deletions
diff --git a/src/handlers/controlCommands.js b/src/handlers/controlCommands.js new file mode 100644 index 0000000..14c73f7 --- /dev/null +++ b/src/handlers/controlCommands.js @@ -0,0 +1,97 @@ +const { playSong, safeCleanup, clearSongBuffer } = require('../utils/player'); +const { getQueueOrReply } = require('../utils/helpers'); + +function handlePause(interaction, queues) { + const queue = getQueueOrReply(interaction, queues, 'Nothing is playing!'); + if (!queue) return; + + queue.player.pause(); + interaction.reply('Paused!'); +} + +function handleResume(interaction, queues) { + const queue = getQueueOrReply(interaction, queues, 'Nothing is paused!'); + if (!queue) return; + + queue.player.unpause(); + interaction.reply('Resumed!'); +} + +function handleSkip(interaction, queues) { + const queue = getQueueOrReply(interaction, queues, 'Nothing to skip!'); + if (!queue || queue.songs.length === 0) { + if (queue) interaction.reply('Nothing to skip!'); + return; + } + + const playerState = queue.player.state.status; + console.log(`[SKIP] Guild: ${interaction.guild.id}, Skipping: ${queue.songs[0]?.title}, Player state: ${playerState}, Queue length: ${queue.songs.length}`); + + clearSongBuffer(queue.songs[0], 'skipped'); + safeCleanup(queue, 'Skip'); + + queue.player.stop(); + interaction.reply(`Skipped! (${queue.songs.length - 1} songs remaining)`); +} + +function handleClear(interaction, queues) { + const queue = getQueueOrReply(interaction, queues, 'Queue is already empty!'); + if (!queue || queue.songs.length === 0) { + if (queue) interaction.reply('Queue is already empty!'); + return; + } + + const currentSong = queue.songs[0]; + const clearedCount = queue.songs.length - 1; + + for (let i = 1; i < queue.songs.length; i++) { + clearSongBuffer(queue.songs[i], 'queue cleared'); + } + + queue.songs = [currentSong]; + console.log(`[QUEUE] Cleared ${clearedCount} songs from queue`); + interaction.reply(`Cleared queue! Only current song remains: ${currentSong.title}`); +} + +function handleLoop(interaction, queues) { + const queue = getQueueOrReply(interaction, queues); + if (!queue) return; + + const mode = interaction.options.getString('mode'); + + if (mode) { + queue.loopMode = mode; + } else { + const modes = ['off', 'song', 'queue']; + const currentIndex = modes.indexOf(queue.loopMode || 'off'); + queue.loopMode = modes[(currentIndex + 1) % modes.length]; + } + + const modeEmojis = { + off: 'Loop disabled', + song: 'Looping current song', + queue: 'Looping queue' + }; + + interaction.reply(modeEmojis[queue.loopMode]); +} + +function handleQuit(interaction, queues) { + const queue = getQueueOrReply(interaction, queues); + if (!queue) return; + + try { queue.player.stop(); } catch (e) {} + safeCleanup(queue, 'Quit'); + queue.connection.destroy(); + queues.delete(interaction.guild.id); + interaction.reply('Left voice channel!'); +} + +module.exports = { + handlePause, + handleResume, + handleSkip, + handleClear, + handleLoop, + handleQuit, +}; diff --git a/src/handlers/playCommand.js b/src/handlers/playCommand.js new file mode 100644 index 0000000..5551d28 --- /dev/null +++ b/src/handlers/playCommand.js @@ -0,0 +1,271 @@ +const { EmbedBuilder } = require('discord.js'); +const { + createAudioPlayer, + joinVoiceChannel, + AudioPlayerStatus, + VoiceConnectionStatus, + entersState, +} = require('@discordjs/voice'); +const { getVideoInfo, playSong, preloadSong, formatDuration, safeCleanup, clearSongBuffer } = require('../utils/player'); +const { requireVoiceChannel } = require('../utils/helpers'); + +function createSongsFromVideos(videos, requestedBy) { + return videos.map(v => ({ + title: v.title, + url: v.url, + duration: v.duration, + thumbnail: v.thumbnail, + requestedBy: requestedBy, + })); +} + +function onPlayerIdle(interaction, queue) { + console.log(`[PLAYER IDLE] Guild: ${interaction.guild.id}, Loop mode: ${queue.loopMode}, Is seeking: ${queue.isSeeking || false}`); + + safeCleanup(queue, 'Player Idle'); + + if (queue.isSeeking) { + console.log(`[IDLE SKIP] Skipping queue shift because seek is in progress`); + return; + } + + if (queue.loopMode === 'song') { + if (queue.songs[0]) { + queue.songs[0].retryCount = 0; + } + playSong(interaction.guild.id, queue); + } else if (queue.loopMode === 'queue') { + const finishedSong = queue.songs.shift(); + queue.songs.push(finishedSong); + if (queue.songs.length > 0) { + playSong(interaction.guild.id, queue); + } + } else { + const finishedSong = queue.songs.shift(); + clearSongBuffer(finishedSong, 'finished playing'); + if (queue.songs.length > 0) { + playSong(interaction.guild.id, queue); + } else { + console.log(`[QUEUE EMPTY] Guild: ${interaction.guild.id}`); + } + } +} + +function onPlayerError(error, interaction, queue) { + console.error('Audio player error:', error); + safeCleanup(queue, 'Player Error'); + + if (queue.isSeeking) { + console.log(`[ERROR DURING SEEK] Not shifting queue, seek in progress`); + queue.isSeeking = false; + return; + } + + queue.songs.shift(); + if (queue.songs.length > 0) { + playSong(interaction.guild.id, queue); + } +} + +function onPlayerAutoPaused(player) { + console.warn('Player auto-paused, attempting to resume...'); + try { + player.unpause(); + } catch (err) { + console.error('Failed to unpause:', err); + } +} + +function onConnectionError(error, queue) { + console.error('Voice connection error:', error); + safeCleanup(queue, 'Connection Error'); +} + +async function onConnectionDisconnected(connection, queues, guildId) { + try { + await Promise.race([ + entersState(connection, VoiceConnectionStatus.Signalling, 5000), + entersState(connection, VoiceConnectionStatus.Connecting, 5000), + ]); + } catch (error) { + connection.destroy(); + queues.delete(guildId); + } +} + +function onConnectionDestroyed(queues, guildId) { + queues.delete(guildId); +} + +function setupPlayerEventListeners(player, interaction, queue) { + player.on(AudioPlayerStatus.Idle, () => onPlayerIdle(interaction, queue)); + player.on('error', (error) => onPlayerError(error, interaction, queue)); + player.on(AudioPlayerStatus.AutoPaused, () => onPlayerAutoPaused(player)); +} + +function setupConnectionEventListeners(connection, queues, guildId, queue) { + connection.on('error', (error) => onConnectionError(error, queue)); + connection.on(VoiceConnectionStatus.Disconnected, () => + onConnectionDisconnected(connection, queues, guildId) + ); + connection.on(VoiceConnectionStatus.Destroyed, () => + onConnectionDestroyed(queues, guildId) + ); +} + +async function createVoiceConnection(voiceChannel, interaction) { + const connection = joinVoiceChannel({ + channelId: voiceChannel.id, + guildId: interaction.guild.id, + adapterCreator: voiceChannel.guild.voiceAdapterCreator, + }); + + try { + await entersState(connection, VoiceConnectionStatus.Ready, 30000); + return connection; + } catch (error) { + console.error('Failed to join voice channel:', error); + connection.destroy(); + throw error; + } +} + +async function initializeQueue(voiceChannel, interaction, queues, songs) { + const player = createAudioPlayer(); + let connection; + + try { + connection = await createVoiceConnection(voiceChannel, interaction); + } catch (error) { + return null; + } + + connection.subscribe(player); + + const queue = { + voiceChannel, + connection, + player, + songs: songs, + volume: 50, + isPlaying: false, + loopMode: 'off', + }; + + queues.set(interaction.guild.id, queue); + + setupPlayerEventListeners(player, interaction, queue); + setupConnectionEventListeners(connection, queues, interaction.guild.id, queue); + + return queue; +} + +function addSongsToQueue(queue, songs, interaction) { + const oldLength = queue.songs.length; + const wasIdle = queue.player.state.status === AudioPlayerStatus.Idle; + + songs.forEach(song => queue.songs.push(song)); + const newLength = queue.songs.length; + + console.log(`[QUEUE ADD] Guild: ${interaction.guild.id}, Added ${songs.length} songs, Queue: ${oldLength} -> ${newLength}, Player state: ${queue.player.state.status}, Was idle: ${wasIdle}`); + + if (wasIdle && oldLength === 0 && queue.songs.length > 0) { + if (queue.isSeeking) { + console.log(`[AUTO-PLAY BLOCKED] Seek in progress, not starting playback`); + } else { + console.log(`[AUTO-PLAY] Starting playback, songs in queue: ${queue.songs.length}`); + playSong(interaction.guild.id, queue); + } + } + + if (queue.songs.length >= 2) { + const nextSong = queue.songs[1]; + if (!nextSong.audioBuffer && !nextSong.isPreloading) { + console.log(`[PRELOAD TRIGGER] Preloading next song: ${nextSong.title}`); + preloadSong(nextSong).catch(() => {}); + } + } + + return wasIdle; +} + +function createNewPlaybackEmbed(firstSong, isPlaylist, songs) { + const embed = new EmbedBuilder() + .setColor(0x00ff00) + .setTitle(isPlaylist ? 'Playlist Added' : 'Now Playing') + .setDescription(`**${firstSong.title}**`) + .addFields( + { name: 'Duration', value: formatDuration(firstSong.duration), inline: true }, + { name: 'Requested by', value: firstSong.requestedBy, inline: true } + ) + .setThumbnail(firstSong.thumbnail); + + if (isPlaylist) { + embed.addFields({ name: 'Playlist', value: `${songs.length} songs added to queue` }); + } + + return embed; +} + +function createAddToQueueEmbed(firstSong, isPlaylist, songs, queuePosition) { + const embed = new EmbedBuilder() + .setColor(0x00ff00) + .setTitle(isPlaylist ? 'Playlist Added to Queue' : 'Added to Queue') + .setDescription(`**${firstSong.title}**`) + .addFields( + { name: 'Position', value: `${queuePosition}`, inline: true }, + { name: 'Requested by', value: firstSong.requestedBy, inline: true } + ) + .setThumbnail(firstSong.thumbnail); + + if (isPlaylist) { + embed.addFields({ name: 'Playlist', value: `${songs.length} songs added to queue` }); + } + + return embed; +} + +async function handlePlay(interaction, queues) { + await interaction.deferReply(); + + const query = interaction.options.getString('query'); + const voiceChannel = requireVoiceChannel(interaction); + if (!voiceChannel) return; + + const videoInfo = await getVideoInfo(query); + if (!videoInfo) { + return interaction.editReply('Could not find that video!'); + } + + const isPlaylist = videoInfo.isPlaylist; + const videos = videoInfo.videos; + + if (!videos || videos.length === 0) { + return interaction.editReply('Could not find any videos!'); + } + + const songs = createSongsFromVideos(videos, interaction.user.tag); + const firstSong = songs[0]; + + let queue = queues.get(interaction.guild.id); + + if (!queue) { + queue = await initializeQueue(voiceChannel, interaction, queues, songs); + + if (!queue) { + return interaction.editReply('Failed to join voice channel! Try again.'); + } + + const embed = createNewPlaybackEmbed(firstSong, isPlaylist, songs); + interaction.editReply({ embeds: [embed] }); + playSong(interaction.guild.id, queue); + } else { + const queuePosition = queue.songs.length - songs.length + 1; + addSongsToQueue(queue, songs, interaction); + + const embed = createAddToQueueEmbed(firstSong, isPlaylist, songs, queuePosition); + interaction.editReply({ embeds: [embed] }); + } +} + +module.exports = { handlePlay }; diff --git a/src/handlers/queueCommand.js b/src/handlers/queueCommand.js new file mode 100644 index 0000000..e0fbee1 --- /dev/null +++ b/src/handlers/queueCommand.js @@ -0,0 +1,38 @@ +const { EmbedBuilder } = require('discord.js'); +const { formatDuration } = require('../utils/player'); +const { getQueueOrReply } = require('../utils/helpers'); + +function handleQueue(interaction, queues) { + const queue = getQueueOrReply(interaction, queues, 'Queue is empty!'); + if (!queue || queue.songs.length === 0) { + if (queue) interaction.reply('Queue is empty!'); + return; + } + + const nowPlaying = queue.songs[0]; + const upcoming = queue.songs.slice(1, 10); + + const embed = new EmbedBuilder() + .setColor(0x0099ff) + .setTitle('Queue') + .setDescription( + `**Now Playing:**\n${nowPlaying.title}\n` + + `Duration: ${formatDuration(nowPlaying.duration)} | Requested by: ${nowPlaying.requestedBy}` + ) + .setThumbnail(nowPlaying.thumbnail); + + if (upcoming.length > 0) { + const upcomingText = upcoming + .map((song, i) => `${i + 1}. ${song.title} - ${formatDuration(song.duration)}`) + .join('\n'); + embed.addFields({ name: 'Up Next', value: upcomingText }); + } + + if (queue.songs.length > 10) { + embed.setFooter({ text: `...and ${queue.songs.length - 10} more` }); + } + + interaction.reply({ embeds: [embed] }); +} + +module.exports = { handleQueue }; diff --git a/src/handlers/seekCommand.js b/src/handlers/seekCommand.js new file mode 100644 index 0000000..e48f603 --- /dev/null +++ b/src/handlers/seekCommand.js @@ -0,0 +1,51 @@ +const { EmbedBuilder } = require('discord.js'); +const { playSong, getCurrentProgress, formatDuration, safeCleanup } = require('../utils/player'); +const { getQueueOrReply } = require('../utils/helpers'); + +function handleSeek(interaction, queues) { + const queue = getQueueOrReply(interaction, queues, 'Nothing is playing!'); + if (!queue || queue.songs.length === 0) { + if (queue) interaction.reply('Nothing is playing!'); + return; + } + + if (queue.isSeeking) { + return interaction.reply('Already seeking, please wait!'); + } + + const seconds = interaction.options.getInteger('seconds'); + const progress = getCurrentProgress(queue); + + if (!progress) { + return interaction.reply('Cannot seek right now!'); + } + + let newPosition = progress.elapsed + seconds; + const song = queue.songs[0]; + + if (newPosition < 0) { + newPosition = 0; + } + + if (newPosition >= song.duration) { + queue.player.stop(); + return interaction.reply('Skipped to next song!'); + } + + queue.isSeeking = true; + safeCleanup(queue, 'Seek'); + + queue.player.stop(); + + console.log(`[SEEK] Guild: ${interaction.guild.id}, From: ${progress.elapsed}s, To: ${newPosition}s`); + + playSong(interaction.guild.id, queue, newPosition); + + const embed = new EmbedBuilder() + .setColor(0x00ff00) + .setDescription(`⏩ Seeking to ${formatDuration(newPosition)} / ${formatDuration(song.duration)}`); + + interaction.reply({ embeds: [embed] }); +} + +module.exports = { handleSeek }; diff --git a/src/handlers/volumeCommand.js b/src/handlers/volumeCommand.js new file mode 100644 index 0000000..dbd99de --- /dev/null +++ b/src/handlers/volumeCommand.js @@ -0,0 +1,28 @@ +const { setQueueVolume } = require('../utils/player'); +const { getQueueOrReply } = require('../utils/helpers'); + +function handleVolume(interaction, queues) { + const queue = getQueueOrReply(interaction, queues, 'Nothing is playing!'); + if (!queue) return; + + const subcommand = interaction.options.getSubcommand(); + + if (subcommand === 'set') { + const value = interaction.options.getInteger('value'); + if (value < 0 || value > 100) { + return interaction.reply('Volume must be between 0 and 100!'); + } + setQueueVolume(queue, value); + interaction.reply(`Volume set to ${value}%`); + } else if (subcommand === 'inc') { + const amount = interaction.options.getInteger('amount') || 10; + const newVolume = setQueueVolume(queue, queue.volume + amount); + interaction.reply(`Volume increased to ${newVolume}%`); + } else if (subcommand === 'dec') { + const amount = interaction.options.getInteger('amount') || 10; + const newVolume = setQueueVolume(queue, queue.volume - amount); + interaction.reply(`Volume decreased to ${newVolume}%`); + } +} + +module.exports = { handleVolume }; |
