diff --git a/Handlers/HandlerResponse.py b/Handlers/HandlerResponse.py index e62b8e5..99a9bcb 100644 --- a/Handlers/HandlerResponse.py +++ b/Handlers/HandlerResponse.py @@ -2,7 +2,7 @@ from typing import Union from discord.ext.commands import Context from Config.Exceptions import VulkanError from discord import Embed, Interaction -from discord.ui import View +from UI.Views.AbstractView import AbstractView class HandlerResponse: @@ -22,7 +22,7 @@ class HandlerResponse: return self.__embed @property - def view(self) -> View: + def view(self) -> AbstractView: return self.__view def error(self) -> Union[VulkanError, None]: diff --git a/Handlers/JumpMusicHandler.py b/Handlers/JumpMusicHandler.py new file mode 100644 index 0000000..47a16f8 --- /dev/null +++ b/Handlers/JumpMusicHandler.py @@ -0,0 +1,80 @@ +from typing import Union +from Config.Exceptions import BadCommandUsage, InvalidInput, NumberRequired, UnknownError, VulkanError +from Handlers.AbstractHandler import AbstractHandler +from discord.ext.commands import Context +from discord import Interaction +from Handlers.HandlerResponse import HandlerResponse +from Music.Playlist import Playlist +from Music.VulkanBot import VulkanBot +from Parallelism.Commands import VCommands, VCommandsType + + +class JumpMusicHandler(AbstractHandler): + """Move a music from a specific position and play it directly""" + + def __init__(self, ctx: Union[Context, Interaction], bot: VulkanBot) -> None: + super().__init__(ctx, bot) + + async def run(self, musicPos: str) -> HandlerResponse: + processManager = self.config.getProcessManager() + processInfo = processManager.getRunningPlayerInfo(self.guild) + if not processInfo: + embed = self.embeds.NOT_PLAYING() + error = BadCommandUsage() + return HandlerResponse(self.ctx, embed, error) + + processLock = processInfo.getLock() + acquired = processLock.acquire(timeout=self.config.ACQUIRE_LOCK_TIMEOUT) + if acquired: + # Try to convert input to int + error = self.__validateInput(musicPos) + if error: + embed = self.embeds.ERROR_EMBED(error.message) + processLock.release() + return HandlerResponse(self.ctx, embed, error) + + # Sanitize the input + playlist: Playlist = processInfo.getPlaylist() + musicPos = self.__sanitizeInput(playlist, musicPos) + + # Validate the position + if not playlist.validate_position(musicPos): + error = InvalidInput() + embed = self.embeds.PLAYLIST_RANGE_ERROR() + processLock.release() + return HandlerResponse(self.ctx, embed, error) + try: + # Move the selected song + playlist.move_songs(musicPos, 1) + + # Send a command to the player to skip the music + command = VCommands(VCommandsType.SKIP, None) + queue = processInfo.getQueueToPlayer() + queue.put(command) + + processLock.release() + return HandlerResponse(self.ctx) + except: + # Release the acquired Lock + processLock.release() + embed = self.embeds.ERROR_MOVING() + error = UnknownError() + return HandlerResponse(self.ctx, embed, error) + else: + processManager.resetProcess(self.guild, self.ctx) + embed = self.embeds.PLAYER_RESTARTED() + return HandlerResponse(self.ctx, embed) + + def __validateInput(self, position: str) -> Union[VulkanError, None]: + try: + position = int(position) + except: + return NumberRequired(self.messages.ERROR_NUMBER) + + def __sanitizeInput(self, playlist: Playlist, position: int) -> int: + position = int(position) + + if position == -1: + position = len(playlist.getSongs()) + + return position diff --git a/Handlers/QueueHandler.py b/Handlers/QueueHandler.py index e84c0e7..5215f87 100644 --- a/Handlers/QueueHandler.py +++ b/Handlers/QueueHandler.py @@ -2,6 +2,7 @@ from discord.ext.commands import Context from Config.Exceptions import InvalidIndex from Handlers.AbstractHandler import AbstractHandler from Handlers.HandlerResponse import HandlerResponse +from Handlers.JumpMusicHandler import JumpMusicHandler from Messages.MessagesCategory import MessagesCategory from UI.Views.BasicView import BasicView from Utils.Utils import Utils @@ -11,6 +12,7 @@ from Music.Playlist import Playlist from typing import List, Union from discord import Button, Interaction from UI.Buttons.EmptyButton import CallbackButton +from UI.Buttons.PlaylistDropdown import PlaylistDropdown from Config.Emojis import VEmojis @@ -38,6 +40,12 @@ class QueueHandler(AbstractHandler): processLock.release() # Release the Lock return HandlerResponse(self.ctx, embed) + allSongs = playlist.getSongs() + if len(allSongs) == 0: + embed = self.embeds.EMPTY_QUEUE() + processLock.release() # Release the Lock + return HandlerResponse(self.ctx, embed) + songsPages = playlist.getSongsPages() if pageNumber < 0 or pageNumber >= len(songsPages): embed = self.embeds.INVALID_INDEX() @@ -45,16 +53,11 @@ class QueueHandler(AbstractHandler): processLock.release() # Release the Lock return HandlerResponse(self.ctx, embed, error) - allSongs = playlist.getSongs() - if len(allSongs) == 0: - embed = self.embeds.EMPTY_QUEUE() - processLock.release() # Release the Lock - return HandlerResponse(self.ctx, embed) - # Select the page in queue to be printed songs = songsPages[pageNumber] # Create view for this embed buttons = self.__createViewButtons(songsPages, pageNumber) + buttons.extend(self.__createViewJumpButtons(playlist)) queueView = BasicView(self.bot, buttons, self.config.QUEUE_VIEW_TIMEOUT) if playlist.isLoopingAll(): @@ -96,3 +99,6 @@ class QueueHandler(AbstractHandler): self.guild.id, MessagesCategory.QUEUE, "Next Page", pageNumber=nextPageNumber)) return buttons + + def __createViewJumpButtons(self, playlist: Playlist) -> List[Button]: + return [PlaylistDropdown(self.bot, JumpMusicHandler, playlist, self.ctx.channel, self.guild.id, MessagesCategory.PLAYER)] diff --git a/Handlers/SkipHandler.py b/Handlers/SkipHandler.py index ae11295..a4cf3ed 100644 --- a/Handlers/SkipHandler.py +++ b/Handlers/SkipHandler.py @@ -26,12 +26,6 @@ class SkipHandler(AbstractHandler): embed = self.embeds.NOT_PLAYING() return HandlerResponse(self.ctx, embed) - playlist = processInfo.getPlaylist() - if playlist.isLoopingOne(): - embed = self.embeds.ERROR_DUE_LOOP_ONE_ON() - error = BadCommandUsage() - return HandlerResponse(self.ctx, embed, error) - # Send a command to the player process to skip the music command = VCommands(VCommandsType.SKIP, None) queue = processInfo.getQueueToPlayer() diff --git a/Messages/Responses/EmbedCogResponse.py b/Messages/Responses/EmbedCogResponse.py index 2e391d6..3e3fac9 100644 --- a/Messages/Responses/EmbedCogResponse.py +++ b/Messages/Responses/EmbedCogResponse.py @@ -8,11 +8,15 @@ class EmbedCommandResponse(AbstractCommandResponse): super().__init__(response, category) async def run(self, deleteLast: bool = True) -> None: + message = None if self.response.embed and self.response.view: message = await self.context.send(embed=self.response.embed, view=self.response.view) + # Set the view to contain the sended message + self.response.view.set_message(message) elif self.response.embed: message = await self.context.send(embed=self.response.embed) + if message: # Only delete the previous message if this is not error and not forbidden by method caller if deleteLast and self.response.success: await self.manager.addMessageAndClearPrevious(self.context.guild.id, self.category, message) diff --git a/Music/Playlist.py b/Music/Playlist.py index 470f9b9..b044486 100644 --- a/Music/Playlist.py +++ b/Music/Playlist.py @@ -88,7 +88,7 @@ class Playlist: self.__current = None return None - self.__current = self.__queue.popleft() + self.__current: Song = self.__queue.popleft() return self.__current def prev_song(self) -> Song: diff --git a/UI/Buttons/AbstractItem.py b/UI/Buttons/AbstractItem.py new file mode 100644 index 0000000..e730a24 --- /dev/null +++ b/UI/Buttons/AbstractItem.py @@ -0,0 +1,12 @@ +from abc import ABC, abstractmethod +from discord.ui import Item, View + + +class AbstractItem(ABC, Item): + @abstractmethod + def set_view(self, view: View): + pass + + @abstractmethod + def get_view(self) -> View: + pass diff --git a/UI/Buttons/EmptyButton.py b/UI/Buttons/EmptyButton.py index 3125864..0fe9e9b 100644 --- a/UI/Buttons/EmptyButton.py +++ b/UI/Buttons/EmptyButton.py @@ -1,7 +1,7 @@ from typing import Awaitable from Config.Emojis import VEmojis from discord import ButtonStyle, Interaction, Message, TextChannel -from discord.ui import Button +from discord.ui import Button, View from Handlers.HandlerResponse import HandlerResponse from Messages.MessagesCategory import MessagesCategory from Messages.MessagesManager import MessagesManager @@ -21,6 +21,7 @@ class CallbackButton(Button): self.__args = args self.__kwargs = kwargs self.__callback = cb + self.__view: View = None async def callback(self, interaction: Interaction) -> None: """Callback to when Button is clicked""" @@ -29,10 +30,19 @@ class CallbackButton(Button): response: HandlerResponse = await self.__callback(*self.__args, **self.__kwargs) + message = None if response and response.view is not None: message: Message = await self.__channel.send(embed=response.embed, view=response.view) - else: + response.view.set_message(message) + elif response.embed: message: Message = await self.__channel.send(embed=response.embed) # Clear the last sended message in this category and add the new one - await self.__messagesManager.addMessageAndClearPrevious(self.__guildID, self.__category, message) + if message: + await self.__messagesManager.addMessageAndClearPrevious(self.__guildID, self.__category, message) + + def set_view(self, view: View): + self.__view = view + + def get_view(self) -> View: + return self.__view diff --git a/UI/Buttons/HandlerButton.py b/UI/Buttons/HandlerButton.py index 3b08bbb..2bac59d 100644 --- a/UI/Buttons/HandlerButton.py +++ b/UI/Buttons/HandlerButton.py @@ -1,6 +1,6 @@ from Config.Emojis import VEmojis from discord import ButtonStyle, Interaction, Message, TextChannel -from discord.ui import Button +from discord.ui import Button, View from Handlers.HandlerResponse import HandlerResponse from Messages.MessagesCategory import MessagesCategory from Music.VulkanBot import VulkanBot @@ -21,6 +21,7 @@ class HandlerButton(Button): self.__args = args self.__kwargs = kwargs self.__handlerClass = handler + self.__view: View = None async def callback(self, interaction: Interaction) -> None: """Callback to when Button is clicked""" @@ -31,10 +32,19 @@ class HandlerButton(Button): handler = self.__handlerClass(interaction, self.__bot) response: HandlerResponse = await handler.run(*self.__args, **self.__kwargs) + message = None if response and response.view is not None: message: Message = await self.__channel.send(embed=response.embed, view=response.view) - else: + response.view.set_message(message) + elif response.embed: message: Message = await self.__channel.send(embed=response.embed) # Clear the last category sended message and add the new one - await self.__messagesManager.addMessageAndClearPrevious(self.__guildID, self.__category, message) + if message: + await self.__messagesManager.addMessageAndClearPrevious(self.__guildID, self.__category, message) + + def set_view(self, view: View): + self.__view = view + + def get_view(self) -> View: + return self.__view diff --git a/UI/Buttons/PlaylistDropdown.py b/UI/Buttons/PlaylistDropdown.py new file mode 100644 index 0000000..209a125 --- /dev/null +++ b/UI/Buttons/PlaylistDropdown.py @@ -0,0 +1,86 @@ +import asyncio +from typing import List +from discord import Interaction, Message, TextChannel, SelectOption +from discord.ui import Select, View +from Handlers.HandlerResponse import HandlerResponse +from Messages.MessagesCategory import MessagesCategory +from Messages.MessagesManager import MessagesManager +from Music.VulkanBot import VulkanBot +from Handlers.AbstractHandler import AbstractHandler +from UI.Buttons.AbstractItem import AbstractItem +from UI.Views.AbstractView import AbstractView +from Music.Playlist import Playlist + + +class PlaylistDropdown(Select, AbstractItem): + """Receives n elements to put in drop down and return the selected, pass the index value to a handler""" + + def __init__(self, bot: VulkanBot, handler: type[AbstractHandler], playlist: Playlist, textChannel: TextChannel, guildID: int, category: MessagesCategory): + songs = list(playlist.getSongs()) + + values = [str(x) for x in range(1, len(songs) + 1)] + # Get the title of each of the 20 first songs, library doesn't accept more + songsNames = [song.title[:80] for song in songs[:20]] + + selectOptions: List[SelectOption] = [] + + for x in range(len(songsNames)): + selectOptions.append(SelectOption(label=songsNames[x], value=values[x])) + + super().__init__(placeholder="Select one music to play now, may be overdue", + min_values=1, max_values=1, options=selectOptions) + + self.__playlist = playlist + self.__channel = textChannel + self.__guildID = guildID + self.__category = category + self.__handlerClass = handler + self.__messagesManager = MessagesManager() + self.__bot = bot + self.__view: AbstractView = None + + async def callback(self, interaction: Interaction) -> None: + """Callback to when the selection is selected""" + await interaction.response.defer() + + # Execute the handler passing the value selected + handler = self.__handlerClass(interaction, self.__bot) + response: HandlerResponse = await handler.run(self.values[0]) + + message = None + if response and response.view is not None: + message: Message = await self.__channel.send(embed=response.embed, view=response.view) + elif response.embed: + message: Message = await self.__channel.send(embed=response.embed) + + # Clear the last sended message in this category and add the new one + if message: + await self.__messagesManager.addMessageAndClearPrevious(self.__guildID, self.__category, message) + + # Extreme ugly way to wait for the player process to actually retrieve the next song + await asyncio.sleep(2) + + await self.__update() + + async def __update(self): + songs = list(self.__playlist.getSongs()) + + values = [str(x) for x in range(1, len(songs) + 1)] + # Get the title of each of the 20 first songs, library doesn't accept more + songsNames = [song.title[:80] for song in songs[:20]] + + selectOptions: List[SelectOption] = [] + + for x in range(len(songsNames)): + selectOptions.append(SelectOption(label=songsNames[x], value=values[x])) + + self.options = selectOptions + + if self.__view is not None: + await self.__view.update() + + def set_view(self, view: View): + self.__view = view + + def get_view(self) -> View: + return self.__view diff --git a/UI/Views/AbstractView.py b/UI/Views/AbstractView.py new file mode 100644 index 0000000..7fe5668 --- /dev/null +++ b/UI/Views/AbstractView.py @@ -0,0 +1,10 @@ +from abc import ABC, abstractmethod + + +class AbstractView(ABC): + @abstractmethod + async def update(self) -> None: + pass + + def set_message(self, message) -> None: + pass diff --git a/UI/Views/BasicView.py b/UI/Views/BasicView.py index 743af7d..ca57cd7 100644 --- a/UI/Views/BasicView.py +++ b/UI/Views/BasicView.py @@ -1,21 +1,25 @@ from typing import List -from discord import Button, Message +from discord import Message from discord.ui import View from Config.Emojis import VEmojis from Music.VulkanBot import VulkanBot +from UI.Views.AbstractView import AbstractView +from UI.Buttons.AbstractItem import AbstractItem emojis = VEmojis() -class BasicView(View): +class BasicView(View, AbstractView): """View that receives buttons to hold, in timeout disable buttons""" - def __init__(self, bot: VulkanBot, buttons: List[Button], timeout: float = 6000): + def __init__(self, bot: VulkanBot, buttons: List[AbstractItem], timeout: float = 6000): super().__init__(timeout=timeout) self.__bot = bot self.__message: Message = None for button in buttons: + # Set the buttons to have a instance of the view that contains them + button.set_view(self) self.add_item(button) async def on_timeout(self) -> None: @@ -29,3 +33,7 @@ class BasicView(View): def set_message(self, message: Message) -> None: self.__message = message + + async def update(self): + if self.__message is not None: + await self.__message.edit(view=self)