diff --git a/cogs/basic.py b/cogs/basic.py index 0a10f96..d6aca4b 100644 --- a/cogs/basic.py +++ b/cogs/basic.py @@ -42,7 +42,7 @@ from function import ( from voicelink import SearchType, LoopType from addons import LYRICS_PLATFORMS -from views import SearchView, ListView, LinkView, LyricsView, HelpView +from views import SearchView, QueueView, LinkView, LyricsView, HelpView from validators import url async def nowplay(ctx: commands.Context, player: voicelink.Player): @@ -493,7 +493,7 @@ class Basic(commands.Cog): if player.queue.is_empty: return await nowplay(ctx, player) - view = ListView(player=player, author=ctx.author) + view = QueueView(player=player, author=ctx.author) view.response = await send(ctx, await view.build_embed(), view=view) @queue.command(name="export", aliases=get_aliases("export")) @@ -577,7 +577,7 @@ class Basic(commands.Cog): if not player.queue.history(): return await nowplay(ctx, player) - view = ListView(player=player, author=ctx.author, is_queue=False) + view = QueueView(player=player, author=ctx.author, is_queue=False) view.response = await send(ctx, await view.build_embed(), view=view) @commands.hybrid_command(name="leave", aliases=get_aliases("leave")) diff --git a/views/__init__.py b/views/__init__.py index 883cfe5..db8e424 100644 --- a/views/__init__.py +++ b/views/__init__.py @@ -30,7 +30,7 @@ class ButtonOnCooldown(commands.CommandError): from .controller import InteractiveController from .search import SearchView from .help import HelpView -from .list import ListView +from .queue import QueueView from .lyrics import LyricsView from .playlist import PlaylistView from .inbox import InboxView diff --git a/views/help.py b/views/help.py index fcb4005..bb9edf2 100644 --- a/views/help.py +++ b/views/help.py @@ -27,7 +27,7 @@ from discord.ext import commands import function as func class HelpDropdown(discord.ui.Select): - def __init__(self, categories:list): + def __init__(self, categories: list[str]) -> None: self.view: HelpView super().__init__( diff --git a/views/list.py b/views/list.py deleted file mode 100644 index 7ee4469..0000000 --- a/views/list.py +++ /dev/null @@ -1,120 +0,0 @@ -"""MIT License - -Copyright (c) 2023 - present Vocard Development - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -""" - -import discord -import function as func - -from math import ceil -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from voicelink import Player, Track - -class ListView(discord.ui.View): - def __init__( - self, - player: "Player", - author: discord.Member, - is_queue: bool = True - ) -> None: - super().__init__(timeout=60) - self.player: Player = player - - self.is_queue: bool = is_queue - self.tracks: list[Track] = player.queue.tracks() if is_queue else player.queue.history() - self.response: discord.Message = None - - if not is_queue: - self.tracks.reverse() - self.author: discord.Member = author - - self.page: int = ceil(len(self.tracks) / 7) - self.current_page: int = 1 - - try: - self.time: str = func.time(sum([track.length for track in self.tracks])) - except Exception as _: - self.time = "∞" - - async def on_timeout(self) -> None: - for child in self.children: - child.disabled = True - try: - await self.response.edit(view=self) - except: - pass - - async def on_error(self, error, item, interaction) -> None: - return - - async def interaction_check(self, interaction: discord.Interaction) -> bool: - return interaction.user == self.author - - async def build_embed(self) -> discord.Embed: - offset: int = self.current_page * 7 - tracks: list[Track] = self.tracks[(offset-7):offset] - texts = await func.get_lang(self.author.guild.id, "viewTitle", "viewDesc", "nowplayingDesc", "live", "queueTitle", "historyTitle", "viewFooter") - - embed = discord.Embed(title=texts[0], color=func.settings.embed_color) - embed.description=texts[1].format(self.player.current.uri, f"```{self.player.current.title}```") if self.player.current else texts[2].format("None") - - embed.description += "\n**" + (texts[4] if self.is_queue else texts[5]) + "**\n" + "\n".join([ - f"{track.emoji} `{i:>2}.` `[" + (texts[3] if track.is_stream else func.time(track.length)) + f']` [{func.truncate_string(track.title)}]({track.uri})' + (track.requester.mention) - for i, track in enumerate(tracks, start=offset-6) - ]) - embed.set_footer(text=texts[6].format(self.current_page, self.page, self.time)) - - return embed - - @discord.ui.button(label='<<', style=discord.ButtonStyle.grey) - async def fast_back_button(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: - if self.current_page != 1: - self.current_page = 1 - return await interaction.response.edit_message(embed=await self.build_embed()) - await interaction.response.defer() - - @discord.ui.button(label='Back', style=discord.ButtonStyle.blurple) - async def back_button(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: - if self.current_page > 1: - self.current_page -= 1 - return await interaction.response.edit_message(embed=await self.build_embed()) - await interaction.response.defer() - - @discord.ui.button(label='Next', style=discord.ButtonStyle.blurple) - async def next_button(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: - if self.current_page < self.page: - self.current_page += 1 - return await interaction.response.edit_message(embed=await self.build_embed()) - await interaction.response.defer() - - @discord.ui.button(label='>>', style=discord.ButtonStyle.grey) - async def fast_next_button(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: - if self.current_page != self.page: - self.current_page = self.page - return await interaction.response.edit_message(embed=await self.build_embed()) - await interaction.response.defer() - - @discord.ui.button(emoji='🗑️', style=discord.ButtonStyle.red) - async def stop_button(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: - await self.response.delete() - self.stop() \ No newline at end of file diff --git a/views/queue.py b/views/queue.py new file mode 100644 index 0000000..ff984c5 --- /dev/null +++ b/views/queue.py @@ -0,0 +1,199 @@ +"""MIT License + +Copyright (c) 2023 - present Vocard Development + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +import discord +import function as func + +from typing import TYPE_CHECKING + +from .utils import Pagination, BaseModal + +if TYPE_CHECKING: + from voicelink import Player, Track + + +class QueueView(discord.ui.View): + """ + A Discord UI view for displaying and interacting with a paginated list of tracks. + + Attributes: + player (Player): The player containing the track queue or history. + author (discord.Member): The member who initiated the view. + is_queue (bool): Indicates if the list is a queue or history. + pagination (Pagination[Track]): Manages pagination of track lists. + response (discord.Message): The message containing the view. + total_duration (str): The total duration of the tracks. + """ + + def __init__(self, player: "Player", author: discord.Member, is_queue: bool = True) -> None: + super().__init__(timeout=60) + self.player: Player = player + self.author: discord.Member = author + self.is_queue: bool = is_queue + self.response: discord.Message = None + + self.pagination = Pagination["Track"]( + items=player.queue.tracks() if is_queue else list(reversed(player.queue.history())), + page_size=7, + ) + + self.total_duration = self.calculate_total_duration() + self.update_view() + + def calculate_total_duration(self) -> str: + """Calculate the total duration of the tracks.""" + try: + return func.time(sum(track.length for track in self.pagination._items)) + except Exception: + return "∞" + + def format_description(self, tracks: list["Track"], texts: list[str]) -> str: + """Format the description for the embed based on current tracks.""" + now_playing = ( + texts[1].format(self.player.current.uri, + f"```{self.player.current.title}```") + if self.player.current else texts[2].format("None") + ) + + track_list = "\n".join([ + f"{track.emoji} `{i:>2}.` `[{texts[3] if track.is_stream else func.time(track.length)}]` " + f"[{func.truncate_string(track.title)}]({track.uri}) {track.requester.mention}" + for i, track in enumerate(tracks, start=self.pagination.start_index + 1) + ]) + + return f"{now_playing}\n**{texts[4] if self.is_queue else texts[5]}**\n{track_list}" + + def update_view(self) -> None: + """Update button states and page number display based on current pagination state.""" + button_states = { + "fast_back": self.pagination.current_page <= 2, + "back": not self.pagination.has_previous_page, + "fast_next": self.pagination.current_page >= self.pagination.total_pages - 1, + "next": not self.pagination.has_next_page, + } + + for child in self.children: + if child.custom_id in button_states: + child.disabled = button_states[child.custom_id] + if child.custom_id == "page_number": + child.label = f"{self.pagination.current_page:02}/{self.pagination.total_pages:02}" + + async def on_timeout(self) -> None: + """Disable all buttons when the view times out.""" + for child in self.children: + child.disabled = True + try: + await self.response.edit(view=self) + except discord.HTTPException: + pass + + async def interaction_check(self, interaction: discord.Interaction) -> bool: + """Ensure only the author of the view can interact with it.""" + return interaction.user == self.author + + async def build_embed(self) -> discord.Embed: + """Build the embed for the current page of tracks.""" + tracks = self.pagination.get_current_page_items() + texts = await func.get_lang( + self.author.guild.id, + "viewTitle", + "viewDesc", + "nowplayingDesc", + "live", + "queueTitle", + "historyTitle", + "viewFooter", + ) + + embed = discord.Embed(title=texts[0], color=func.settings.embed_color) + embed.description = self.format_description(tracks, texts) + + embed.set_footer( + text=texts[6].format( + self.pagination.current_page, + self.pagination.total_pages, + self.total_duration, + ) + ) + + return embed + + async def update_and_edit_message(self, interaction: discord.Interaction) -> None: + """Update the view and edit the message with the new embed.""" + self.update_view() + + if interaction.response.is_done(): + await interaction.followup.edit_message(self.response.id, embed=await self.build_embed(), view=self) + else: + await interaction.response.edit_message(embed=await self.build_embed(), view=self) + + @discord.ui.button(label='<<', custom_id="fast_back") + async def fast_back_button(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: + """Jump to the first page.""" + self.pagination.go_page(0) + await self.update_and_edit_message(interaction) + + @discord.ui.button(label='Back', custom_id="back", style=discord.ButtonStyle.blurple) + async def back_button(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: + """Go to the previous page if it exists.""" + self.pagination.go_back() + await self.update_and_edit_message(interaction) + + @discord.ui.button(label="--/--", custom_id="page_number", style=discord.ButtonStyle.blurple) + async def page_number(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: + """Display current page number.""" + modal = BaseModal( + title="Page Number", + custom_id="page_number_modal", + items=[ + discord.ui.TextInput( + label="Page Number", + custom_id="page_number", + placeholder="Enter the page number to navigate.", + default=str(self.pagination.current_page), + max_length=5, + required=True + ) + ] + ) + await interaction.response.send_modal(modal) + await modal.wait() + + page_number = modal.values.get("page_number") + if not page_number or not page_number.isdigit(): + return + + self.pagination.go_page(int(page_number) - 1) + await self.update_and_edit_message(interaction) + + @discord.ui.button(label='Next', custom_id="next", style=discord.ButtonStyle.blurple) + async def next_button(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: + """Go to the next page if it exists.""" + self.pagination.go_next() + await self.update_and_edit_message(interaction) + + @discord.ui.button(label='>>', custom_id="fast_next") + async def fast_next_button(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: + """Jump to the last page.""" + self.pagination.go_page(self.pagination.total_pages - 1) + await self.update_and_edit_message(interaction) diff --git a/views/utils/__init__.py b/views/utils/__init__.py new file mode 100644 index 0000000..c2902b7 --- /dev/null +++ b/views/utils/__init__.py @@ -0,0 +1,25 @@ +"""MIT License + +Copyright (c) 2023 - present Vocard Development + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +from .pagination import Pagination +from .modal import BaseModal \ No newline at end of file diff --git a/views/utils/modal.py b/views/utils/modal.py new file mode 100644 index 0000000..ea143be --- /dev/null +++ b/views/utils/modal.py @@ -0,0 +1,44 @@ +"""MIT License + +Copyright (c) 2023 - present Vocard Development + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +import discord + + +class BaseModal(discord.ui.Modal): + def __init__(self, title: str, items: list[discord.ui.Item], timeout: float = None, custom_id: str = None) -> None: + super().__init__(title=title, timeout=timeout, custom_id=custom_id) + self._add_items(items) + self.values: dict[str, str] = {} + + def _add_items(self, items: list[discord.ui.Item]) -> None: + """Add items to the modal.""" + for item in items: + self.add_item(item) + + async def on_submit(self, interaction: discord.Interaction) -> None: + """Handle the modal submission.""" + await interaction.response.defer() + + for item in self.walk_children(): + if isinstance(item, discord.ui.TextInput): + self.values[item.custom_id] = item.value diff --git a/views/utils/pagination.py b/views/utils/pagination.py new file mode 100644 index 0000000..7bfdbd1 --- /dev/null +++ b/views/utils/pagination.py @@ -0,0 +1,131 @@ +"""MIT License + +Copyright (c) 2023 - present Vocard Development + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +from typing import List, Generic, TypeVar +from math import ceil + +T = TypeVar("T") + + +class Pagination(Generic[T]): + """ + A class to manage pagination for a list of items. + + Attributes: + items (List[T]): The list of items to paginate. + page_size (int): The number of items per page. + current_page (int): The current page index (0-based). + total_pages (int): The total number of pages. + """ + + def __init__(self, items: List[T], page_size: int): + """ + Initializes the Pagination class. + + Args: + items (List[T]): The list of items to paginate. + page_size (int): The number of items per page. + + Raises: + ValueError: If page_size is less than or equal to 0. + """ + if page_size <= 0: + raise ValueError("page_size must be greater than 0") + + self._items: List[T] = items + self._page_size: int = page_size + self._current_page: int = 0 + self.total_pages: int = ceil(len(items) / page_size) + + def get_current_page_items(self) -> List[T]: + """ + Retrieves the items for the current page. + + Returns: + List[T]: The items on the current page. + """ + return self._items[self.start_index:self.end_index] + + def go_back(self) -> None: + """Moves to the previous page if available.""" + if self.has_previous_page: + self._current_page -= 1 + + def go_next(self) -> None: + """Moves to the next page if available.""" + if self.has_next_page: + self._current_page += 1 + + def go_page(self, page_number: int) -> None: + """Navigate to a specific page, clamped between 0 and total_pages.""" + self._current_page = max(0, min(page_number, self.total_pages - 1)) + + @property + def has_next_page(self) -> bool: + """ + Checks if there is a next page. + + Returns: + bool: True if there is a next page, False otherwise. + """ + return self._current_page < self.total_pages - 1 + + @property + def has_previous_page(self) -> bool: + """ + Checks if there is a previous page. + + Returns: + bool: True if there is a previous page, False otherwise. + """ + return self._current_page > 0 + + @property + def start_index(self) -> int: + """ + Gets the start index of the items for the current page. + + Returns: + int: The start index (0-based) of the current page. + """ + return self._current_page * self._page_size + + @property + def end_index(self) -> int: + """ + Gets the end index of the items for the current page. + + Returns: + int: The end index (exclusive) of the current page. + """ + return min(self.start_index + self._page_size, len(self._items)) + + @property + def current_page(self) -> int: + """ + Returns the current page number (1-based). + + Returns: + int: The current page number, starting from 1. + """ + return self._current_page + 1