Bug fixes for selector and scroller

This commit is contained in:
Ev-1 committed 2022-10-01 15:19:06 +02:00
1 parent df1745c2a8
commit 6b955b8f5f
6 files changed
+206 -133

No files matched your search

+3 -6
View File
@@ -1,9 +1,7 @@
# Discord Packages
from discord.ext import commands
from musicbot.utils.userinteraction.scroller import ScrollClearSettings
from ...utils.userinteraction import Scroller
from ...utils.userinteraction.scroller import Scroller, ScrollClear
from .helpformatter import coghelper, commandhelper, helper, prefix_cleaner
@@ -34,6 +32,5 @@ class Help(commands.Cog):
else:
paginator = await coghelper(ctx, thing)
scroller = Scroller(ctx, paginator,
ScrollClearSettings.OnTimeout | ScrollClearSettings.OnInteractionExit)
await scroller.start_scrolling()
scroller = Scroller(ctx, paginator)
await scroller.start_scrolling(ScrollClear.OnTimeout | ScrollClear.OnInteractionExit)
+142 -85
View File
@@ -1,4 +1,5 @@
# Discord Packages
from os import remove
import discord
import lavalink
import asyncio
@@ -7,8 +8,9 @@ from discord.ext import commands, tasks
import re
from bs4 import BeautifulSoup
from musicbot.utils.mixplayer.player import MixPlayer
from musicbot.utils.userinteraction.scroller import ScrollClearSettings
from musicbot.utils.userinteraction.scroller import ScrollClear
from ...utils import checks, thumbnailer, timeformatter
from ...utils.userinteraction import QueuePaginator, Scroller, Selector, TextPaginator
@@ -164,8 +166,7 @@ class Music(commands.Cog):
if not player.is_playing:
await player.play()
@commands.command(name='seek')
@checks.dj_or(alone=True, track_requester=True)
@require_voice_connection()
@@ -229,33 +230,29 @@ class Music(commands.Cog):
embed = discord.Embed(color=ctx.me.color, title='{stop}')
await ctx.send(embed=ctx.localizer.format_embed(embed))
@commands.command(name='now')
@require_playing()
async def _now(self, ctx):
embed = self.get_current_song_embed(ctx, include_time=True)
embed = ctx.localizer.format_embed(embed)
await ctx.send(embed=embed)
@commands.command(name='queue')
@require_queue(require_member_queue=True)
async def _queue(self, ctx, *, member: discord.Member = None):
""" Shows the player's queue. """
player = self.bot.lavalink.player_manager.get(ctx.guild.id)
pagified_queue = QueuePaginator(ctx.localizer, player, color=ctx.me.color, member=member)
scroller = Scroller(ctx, pagified_queue, ScrollClearSettings.OnInteractionExit | ScrollClearSettings.OnTimeout)
await scroller.start_scrolling()
scroller = Scroller(ctx, pagified_queue)
await scroller.start_scrolling(ScrollClear.OnInteractionExit | ScrollClear.OnTimeout)
@commands.command(name='myqueue')
@require_queue(require_author_queue=True)
async def _myqueue(self, ctx):
""" Shows your queue. """
await self._queue(ctx, member=ctx.author)
@commands.command(name='pause')
@checks.dj_or(alone=True)
@require_voice_connection()
@@ -340,26 +337,55 @@ class Music(commands.Cog):
@require_voice_connection()
@require_queue(require_author_queue=True)
async def _remove(self, ctx):
player = self.bot.lavalink.player_manager.get(ctx.guild.id)
player: MixPlayer = self.bot.lavalink.player_manager.get(ctx.guild.id)
user_queue = player.user_queue(ctx.author.id, dual=True)
functions = [player.remove_user_track]*len(user_queue)
arguments = [(ctx.author.id, i) for i in range(len(user_queue))]
identifiers = []
for index, temp in enumerate(user_queue):
track, globpos = temp
identifiers.append(ctx.localizer.format_str("{queue.usertrack}", _index=index+1,
_globalindex=globpos+1, _title=track.title,
_uri=track.uri))
tracks_to_remove = []
async def update_remove_list(tracks_list, track):
if track in tracks_list:
tracks_list.remove(track)
else:
tracks_list.append(track)
selector = Selector(ctx, identifiers, functions, arguments, num_selections=5, color=0xFF0000,
title='Remove song plz')
message, _, removed = await selector.start_scrolling()
if removed:
await message.edit(content=ctx.localizer.format_str("{remove}", _title=removed.title), embed=None)
else:
await message.delete()
# The callback from the selector takes a discord interaction and
# the button class. We wrap the track remove callback to handle those
# arguments and update button color
def add_change_button_color(func, *args):
async def inner(_, button: SelectorButton):
if button.style == discord.ButtonStyle.red:
button.style = discord.ButtonStyle.gray
else:
button.style = discord.ButtonStyle.red
return await func(*args)
return inner
selector_buttons = []
for index, (track, _) in enumerate(user_queue, start=1):
selector_buttons.append(
SelectorItem(f'`{index}` [{track.title}]({track.uri})`', str(index),
add_change_button_color(update_remove_list, tracks_to_remove, track)))
remove_selector = Selector2(ctx, selector_buttons, terminate_on_select=False, check_for_stop=True,
color=ctx.me.color, title='Select songs to remove')
await remove_selector.start_scrolling(ScrollClear.OnTimeout | ScrollClear.OnInteractionExit)
# If any tracks were removed create a scroller for navigating them
if tracks_to_remove:
paginator = TextPaginator(color=ctx.me.color, title="Removed")
# If the user spent a long time selecting songs the queue might have changed
user_queue = player.user_queue(ctx.author.id, dual=False)
track_indexes_to_remove = [i for i, track in enumerate(user_queue) if track in tracks_to_remove]
# Sort in reverse order since songs shift index when deleting
for track in sorted(track_indexes_to_remove, reverse=True):
if removed_track := player.remove_user_track(ctx.author.id, track):
paginator.add_line(f"{removed_track.title}")
paginator.close_page()
scroller = Scroller(ctx, paginator)
await scroller.start_scrolling(ScrollClear.OnInteractionExit | ScrollClear.OnTimeout)
@commands.command(name="DJremove")
@checks.dj_or()
@@ -398,39 +424,6 @@ class Music(commands.Cog):
await ctx.send(embed=embed)
@commands.command(name='search2')
@require_voice_connection(should_connect=True)
async def _search2(self, ctx, *, query):
player = self.bot.lavalink.player_manager.get(ctx.guild.id)
if not query.startswith('ytsearch:') and not query.startswith('scsearch:'):
query = 'ytsearch:' + query
results = await player.node.get_tracks(query)
embed = discord.Embed(description='{nothing_found}', color=0x36393F)
if not results or not results['tracks']:
embed = ctx.localizer.format_embed(embed)
return await ctx.send(embed=embed)
buttons = []
for track in results['tracks']:
duration = timeformatter.format_ms(int(track.duration))
interaction = SelectorItem(f'[{track.title}]({track.uri}) `{duration}`',
wrap_in_button_callback(self.enqueue, ctx, track, discord.Embed(color=ctx.me.color)))
buttons.append(interaction)
search_selector = Selector2(ctx, buttons, color=ctx.me.color, title=ctx.localizer.format_str('{results}'))
message, callback_results = await search_selector.start_scrolling()
song_embed, song_added = callback_results[0]
if song_added:
embed = ctx.localizer.format_embed(song_embed)
await message.edit(embed=embed, view=None)
if not player.is_playing:
await player.play()
@commands.command(name='search')
@require_voice_connection(should_connect=True)
async def _search(self, ctx, *, query):
@@ -446,34 +439,27 @@ class Music(commands.Cog):
embed = ctx.localizer.format_embed(embed)
return await ctx.send(embed=embed)
tracks = results['tracks']
result_count = min(len(tracks), 15)
# Setup for the selector
identifiers = []
functions = [self.enqueue]*result_count
arguments = [(ctx, tracks[i], discord.Embed(color=ctx.me.color)) for i in range(result_count)]
for index, track in enumerate(tracks, start=1):
buttons = []
for i, track in enumerate(results['tracks'], start=1):
duration = timeformatter.format_ms(int(track.duration))
identifiers.append(f'`{index}.` [{track.title}]({track.uri}) `{duration}`')
if index == result_count:
break
interaction = SelectorItem(f'`{i}` [{track.title}]({track.uri}) `{duration}`', str(i),
wrap_in_button_callback(
self.enqueue, ctx, track, discord.Embed(color=ctx.me.color)))
buttons.append(interaction)
search_selector = Selector2(ctx, buttons, color=ctx.me.color, title=ctx.localizer.format_str('{results}'))
message, callback_results = await search_selector.start_scrolling()
search_selector = Selector(ctx, identifiers, functions, arguments, num_selections=5,
color=ctx.me.color, title=ctx.localizer.format_str('{results}'))
# Let the user scroll through results
message, current_page, result = await search_selector.start_scrolling()
if len(callback_results) == 0:
return await message.delete()
if result:
(embed, added) = result
embed = ctx.localizer.format_embed(embed)
await message.edit(embed=embed)
song_embed, song_added = callback_results[0]
if song_added:
embed = ctx.localizer.format_embed(song_embed)
await message.edit(embed=embed, view=None)
if not player.is_playing:
await player.play()
else:
await message.delete()
@commands.command(name='disconnect')
@require_voice_connection()
@@ -703,7 +689,78 @@ class Music(commands.Cog):
await ctx.send(embed=page)
else:
paginator.add_page_indicator(ctx.localizer)
await Scroller(ctx, paginator).start_scrolling()
await Scroller(ctx, paginator).start_scrolling(ScrollClear.OnTimeout | ScrollClear.OnInteractionExit)
@commands.command(name='scrub2')
@checks.dj_or(alone=True)
@require_voice_connection()
@require_playing(require_user_listening=True)
async def _scrub2(self, ctx):
""" Lists the first 10 search results from a given query. """
player: MixPlayer = self.bot.lavalink.player_manager.get(ctx.guild.id)
controls = '{scrub.controls}'
scrubber = [
('\N{BLACK LEFT-POINTING DOUBLE TRIANGLE WITH VERTICAL BAR}', player.seek, -1000),
('\N{BLACK LEFT-POINTING DOUBLE TRIANGLE}', player.seek, -15),
('\N{DOUBLE VERTICAL BAR}', player.set_pause, True),
('\N{BLACK RIGHT-POINTING TRIANGLE}', player.set_pause, False),
('\N{BLACK RIGHT-POINTING DOUBLE TRIANGLE}', player.seek, 15),
('\N{BLACK RIGHT-POINTING DOUBLE TRIANGLE WITH VERTICAL BAR}', player.seek, 1000),
]
selection = None
arg = None
def check(reaction, user):
if user is None or user.id != ctx.author.id:
return False
if reaction.message.id != msg.id:
return False
for (emoji, func, _arg) in scrubber:
if emoji == reaction.emoji:
nonlocal selection
nonlocal arg
selection = func
arg = _arg
return True
return False
embed = discord.Embed(description='{scrub.add}', color=ctx.me.color)
embed = ctx.localizer.format_embed(embed)
msg = await ctx.send(embed=embed)
for (emoji, _, _) in scrubber:
await msg.add_reaction(emoji)
embed.description = controls
embed = ctx.localizer.format_embed(embed)
await msg.edit(embed=embed)
scrubbing = True
while scrubbing:
try:
reaction, user = await self.bot.wait_for('reaction_add', timeout=15.0, check=check)
except asyncio.TimeoutError:
# PEP8EDIT scrolling = False
try:
await msg.delete()
await ctx.message.delete()
except (discord.Forbidden, discord.HTTPException):
pass
finally:
break
try:
await msg.remove_reaction(reaction, user)
except discord.Forbidden:
pass
if selection is not None:
if not isinstance(arg, bool):
arg = player.position + arg * 1000
await selection(arg)
@commands.command(name='scrub')
@checks.dj_or(alone=True)
+3 -2
View File
@@ -4,6 +4,7 @@ from lavalink import AudioTrack
from collections import OrderedDict, deque
from itertools import chain, cycle, islice
from random import shuffle
from typing import Union, List
def roundrobin(*iterables):
@@ -22,7 +23,7 @@ def roundrobin(*iterables):
class MixQueue:
def __init__(self):
self.queues = OrderedDict()
self.queues: OrderedDict[int, List[AudioTrack]] = OrderedDict()
self.priority_queue = []
self._history = deque(maxlen=11) # 10 + current
self.looping = False
@@ -131,7 +132,7 @@ class MixQueue:
else:
self.queues.pop(requester)
def remove_user_track(self, requester: int, pos: int):
def remove_user_track(self, requester: int, pos: int) -> Union[None, AudioTrack]:
user_queue = self.queues.get(requester)
if user_queue is not None:
if pos < len(user_queue):
+3 -2
View File
@@ -5,6 +5,7 @@ from lavalink import AudioTrack, DefaultPlayer, Node
from lavalink.events import QueueEndEvent, TrackEndEvent, TrackExceptionEvent, TrackStartEvent, TrackStuckEvent
import typing
from typing import Union
from .mixqueue import MixQueue
@@ -13,7 +14,7 @@ class MixPlayer(DefaultPlayer):
def __init__(self, guild_id: int, node: Node):
super().__init__(guild_id, node)
self.queue = MixQueue()
self.queue: MixQueue = MixQueue()
self.listeners = set()
self.voteables = {}
@@ -35,7 +36,7 @@ class MixPlayer(DefaultPlayer):
def remove_user_queue(self, requester: int):
self.queue.remove_user_queue(requester)
def remove_user_track(self, requester: int, pos: int):
def remove_user_track(self, requester: int, pos: int) -> Union[None, AudioTrack]:
""" Removes the song at <pos> from the queue of requester """
return self.queue.remove_user_track(requester, pos)
+33 -22
View File
@@ -4,8 +4,9 @@ from enum import Flag, auto
from lavalink.client import asyncio
from .paginators import BasePaginator, CantScrollException
from typing import Union
class ScrollClearSettings(Flag):
class ScrollClear(Flag):
OnTimeout = auto()
OnInteractionExit = auto()
@@ -23,14 +24,11 @@ class ScrollerNav(discord.ui.Select):
class Scroller:
def __init__(self, ctx, paginator, clear_mode: ScrollClearSettings, timeout=20.0,
with_nav_bar: bool = True):
def __init__(self, ctx, paginator, timeout=20.0, check_for_stop: bool = False):
if not isinstance(paginator, BasePaginator):
raise TypeError('Paginator needs to be a subclass of BasePaginator.')
self.clear_mode = clear_mode
self.use_nav_bar = with_nav_bar
self.paginator = paginator
self.ctx = ctx
@@ -40,21 +38,23 @@ class Scroller:
self.bot = ctx.bot
self.channel = ctx.channel
self.message = None
self.message: Union[discord.Message, None] = None
# Initialize the view we'll be using for scrolling
self.view = discord.ui.View(timeout=timeout)
self.view.on_timeout = self.on_timeout
self.is_scrolling_paginator = len(self.paginator.pages) > 1
self.use_nav_bar = len(self.paginator.pages) > 3
self.scrolling_done = asyncio.Event()
stop_emoji = '✔️' if check_for_stop else '❌'
self.interaction_mapping = [
('\N{BLACK LEFT-POINTING DOUBLE TRIANGLE WITH VERTICAL BAR}', self.first_page),
('\N{BLACK LEFT-POINTING TRIANGLE}', self.previous_page),
('\N{BLACK RIGHT-POINTING TRIANGLE}', self.next_page),
('\N{BLACK RIGHT-POINTING DOUBLE TRIANGLE WITH VERTICAL BAR}', self.last_page),
('❌', self.stop),
(stop_emoji, self.stop),
]
bot_user = ctx.guild if ctx.guild.me is not None else ctx.bot.user
@@ -66,22 +66,22 @@ class Scroller:
if not self.permissions.send_messages:
raise CantScrollException('Bot cannot send messages.')
async def start_scrolling(self) -> discord.Message:
async def start_scrolling(self, clear_mode: ScrollClear) -> discord.Message:
self.clear_mode = clear_mode
self.current_page_number = 0
self.build_view()
if not self.is_scrolling_paginator:
return await self.channel.send(embed=self.paginator.pages[0])
self.message = await self.channel.send(embed=self.paginator.pages[0], view=self.view)
await self.scrolling_done.wait()
return self.message
def update_view(self, interaction: discord.Interaction):
self.navigator.placeholder = f"Page: {self.current_page_number + 1}/{len(self.paginator.pages)}"
if self.is_scrolling_paginator and self.use_nav_bar:
self.navigator.placeholder = f"Page: {self.current_page_number + 1}/{len(self.paginator.pages)}"
def build_view(self):
if not self.is_scrolling_paginator:
return
for (reaction, callback) in self.interaction_mapping:
self.view.add_item(item=ScrollerButton(callback=callback, label=reaction, row=3))
@@ -95,13 +95,19 @@ class Scroller:
self.is_scrolling_paginator = False
self.view.stop()
self.view.clear_items()
if (user_stopped and self.clear_mode & ScrollClearSettings.OnInteractionExit or
not user_stopped and self.clear_mode & ScrollClearSettings.OnTimeout):
await self.message.delete()
self.message = None
await self.ctx.message.delete()
if (user_stopped and self.clear_mode & ScrollClear.OnInteractionExit or
not user_stopped and self.clear_mode & ScrollClear.OnTimeout):
if self.message:
await self.message.delete()
self.message = None
# If this fails the message is not accessable either way, which
# means it is probably deleted
try:
await self.ctx.message.delete()
except:
pass
else:
await self.message.edit(embed=self.paginator.pages[self.current_page_number])
await self.update_message()
# Notify the start_scrolling function that we're done
self.scrolling_done.set()
@@ -117,9 +123,13 @@ class Scroller:
if interaction.message:
# Update the view before we edit the message
self.update_view(interaction)
await interaction.message.edit(embed=self.paginator.pages[page], view=self.view)
await self.update_message()
await interaction.response.defer()
async def update_message(self):
if self.message:
await self.message.edit(embed=self.paginator.pages[self.current_page_number], view=self.view)
async def first_page(self, interaction: discord.Interaction):
await self._scroll(0, interaction)
@@ -135,7 +145,8 @@ class Scroller:
async def navigate(self, interaction: discord.Interaction):
await self._scroll(int(self.navigator.values[0]), interaction)
async def stop_scrolling(self, _: discord.Interaction):
async def stop_scrolling(self, interaction: discord.Interaction):
await interaction.response.defer()
await self.stop(user_stopped=True)
async def on_timeout(self):
+22 -16
View File
@@ -6,8 +6,10 @@ import asyncio
import inspect
from typing import List, Callable, Coroutine
from discord.ext.commands import check
from .paginators import TextPaginator
from .scroller import ScrollClearSettings, Scroller
from .scroller import ScrollClear, Scroller
class SelectorButton(discord.ui.Button):
@@ -16,8 +18,8 @@ class SelectorButton(discord.ui.Button):
super().__init__(label=label, style=style, **kwargs)
# The callback we need to supply to disord.py takes only a discord.Interaction
# we want to have access to the button that caused that was clicked
# so we can update it, i.e. change color
# we want to have access to the button that was clicked (self)
# so we can update or modify it upon it being pressed i.e. change color.
def add_button_to_callback(func):
async def wrapped_callback(interaction: discord.Interaction):
await func(interaction, self)
@@ -27,13 +29,14 @@ class SelectorButton(discord.ui.Button):
class SelectorItem:
def __init__(self, identifier, callback: Callable[[discord.Interaction, SelectorButton], Coroutine]):
def __init__(self, identifier, button_label, callback: Callable[[discord.Interaction, SelectorButton], Coroutine]):
self.button_label = button_label
self.identifier = identifier
self.callback = callback
class Selector2(TextPaginator, Scroller):
def __init__(self, ctx, choices: List[SelectorItem], round_titles=None,
def __init__(self, ctx, choices: List[SelectorItem], round_titles=None, check_for_stop: bool = False,
terminate_on_select: bool = True, max_size=2000, **embed_base):
self.match = None
if round_titles is None:
@@ -42,16 +45,15 @@ class Selector2(TextPaginator, Scroller):
self.terminate_on_select = terminate_on_select
self.round_titles = round_titles
self.max_selections_per_page = 5
self.selections_per_page = 5
self.selections = choices
self.max_selections = min(len(self.selections), self.max_selections_per_page)
TextPaginator.__init__(self, max_size=max_size, max_lines=self.max_selections_per_page, **embed_base)
for index, selection in enumerate(self.selections, start=1):
self.add_line(f"`{index}.` {selection.identifier}")
TextPaginator.__init__(self, max_size=max_size, max_lines=self.selections_per_page, **embed_base)
for selection in self.selections:
self.add_line(selection.identifier)
self.close_page()
Scroller.__init__(self, ctx, self, ScrollClearSettings.OnTimeout)
Scroller.__init__(self, ctx, self, check_for_stop=check_for_stop)
# Contains a list of all buttons that can be interacted with, both
# currently visible and not.
@@ -64,6 +66,8 @@ class Selector2(TextPaginator, Scroller):
# after we have finished interacting with the scroller
self.callback_results = []
# Wrap the provided so that we can both update the view if needed
# or terminate the selection process upon interaction
def with_update_view(func):
async def wrapped_callback(interaction: discord.Interaction, button: SelectorButton):
result = await func(interaction, button)
@@ -72,10 +76,12 @@ class Selector2(TextPaginator, Scroller):
await interaction.response.defer()
if terminate_on_select:
await self.stop(True)
else:
await self.update_message()
return wrapped_callback
for i, choice in enumerate(self.selections):
self.buttons.append(SelectorButton(label=i+1, callback=with_update_view(choice.callback), row=0))
self.buttons.append(SelectorButton(label=choice.button_label, callback=with_update_view(choice.callback), row=0))
def build_view(self):
super().build_view()
@@ -91,15 +97,15 @@ class Selector2(TextPaginator, Scroller):
self.visible_buttons = []
self.current_page_number
start = self.current_page_number * self.max_selections_per_page
end = min((self.current_page_number + 1) * self.max_selections_per_page, len(self.selections))
start = self.current_page_number * self.selections_per_page
end = min((self.current_page_number + 1) * self.selections_per_page, len(self.selections))
for button in self.buttons[start:end]:
self.view.add_item(item=button)
self.visible_buttons.append(button)
async def start_scrolling(self):
message = await super().start_scrolling()
async def start_scrolling(self, clear_mode: ScrollClear=ScrollClear.OnTimeout):
message = await super().start_scrolling(clear_mode)
return message, self.callback_results