From d287a8c8f2695931ba915fd733bb230e930ed5b9 Mon Sep 17 00:00:00 2001 From: Marcin Macierzanka Date: Fri, 21 Aug 2026 17:35:48 +0200 Subject: [PATCH] feat: add interactive Discord player panel --- zyrarbot/ui/bot/client.py | 2 + zyrarbot/ui/bot/cogs/player_cog.py | 155 +++++++++++++++++++++++++++++ zyrarbot/ui/bot/cogs/queue_cog.py | 77 ++++++++++++-- zyrarbot/ui/bot/cogs/voice_cog.py | 32 ++++++ 4 files changed, 257 insertions(+), 9 deletions(-) create mode 100644 zyrarbot/ui/bot/cogs/player_cog.py diff --git a/zyrarbot/ui/bot/client.py b/zyrarbot/ui/bot/client.py index 212e76f..9f3a465 100644 --- a/zyrarbot/ui/bot/client.py +++ b/zyrarbot/ui/bot/client.py @@ -13,6 +13,7 @@ from zyrarbot.storage.history_repository import HistoryRepository from zyrarbot.storage.uploads_repository import UploadsRepository from zyrarbot.ui.bot.cogs.control_cog import ControlCog from zyrarbot.ui.bot.cogs.playback_cog import PlaybackCog +from zyrarbot.ui.bot.cogs.player_cog import PlayerCog from zyrarbot.ui.bot.cogs.queue_cog import QueueCog from zyrarbot.ui.bot.cogs.settings_cog import SettingsCog from zyrarbot.ui.bot.cogs.voice_cog import VoiceCog @@ -53,6 +54,7 @@ class MusicBot(commands.Bot): return ( PlaybackCog(self, self.player, self.tracks, self.uploads), + PlayerCog(self, self.player), QueueCog(self, self.player, self.settings), ControlCog(self, self.player), VoiceCog(self, self.player), diff --git a/zyrarbot/ui/bot/cogs/player_cog.py b/zyrarbot/ui/bot/cogs/player_cog.py new file mode 100644 index 0000000..406cf35 --- /dev/null +++ b/zyrarbot/ui/bot/cogs/player_cog.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +import inspect + +import discord +from discord import app_commands + +from zyrarbot.music.music_player import MusicPlayer +from zyrarbot.ui.bot.cogs.common import MusicCogBase + + +def player_embed(player: MusicPlayer, guild: discord.Guild) -> discord.Embed: + state = player.state(guild.id) + current = state.current + if current is None: + description = "Nic nie jest obecnie odtwarzane." + else: + title = discord.utils.escape_markdown(current.title or "Nieznany tytuł") + plays = player.plays_left(current, guild.id) + description = f"▶️ **{title}**\nPozostałe odtworzenia: **{plays}**" + if current.has_web_link: + description = f"[{description}]({current.display_url})" + + embed = discord.Embed(title=f"🎵 Odtwarzacz — {guild.name}", description=description) + embed.add_field(name="Kolejka", value=f"{len(state.queue)} utworów", inline=True) + embed.add_field(name="Głośność", value=f"{int(state.volume * 100)}%", inline=True) + embed.add_field(name="Zapętlanie", value=state.loop_mode.label, inline=True) + if state.queue: + upcoming = [] + for position, track in enumerate(state.queue[:10], start=1): + marker = "▶️ " if track is current else "" + upcoming.append(f"{marker}{position}. {track.title or 'Nieznany tytuł'}") + embed.add_field(name="Najbliższe utwory", value="\n".join(upcoming), inline=False) + embed.set_footer(text="Przyciski sterują odtwarzaczem na tym serwerze") + return embed + + +class PlayerView(discord.ui.View): + def __init__(self, player: MusicPlayer, guild: discord.Guild): + super().__init__(timeout=1800) + self.player = player + self.guild_id = guild.id + + async def refresh(self, interaction: discord.Interaction) -> None: + if interaction.message: + guild = interaction.guild + if guild: + await interaction.message.edit(embed=player_embed(self.player, guild), view=self) + + async def action(self, interaction: discord.Interaction, operation) -> None: + try: + result = operation() + if inspect.isawaitable(result): + await result + await self.player.trigger_state_change() + if interaction.guild is None: + return + await interaction.response.edit_message( + embed=player_embed(self.player, interaction.guild), view=self + ) + except (discord.DiscordException, RuntimeError, ValueError) as error: + await interaction.response.send_message( + f"❌ Nie udało się wykonać akcji: {error}", ephemeral=True + ) + + @discord.ui.button(label="Pauza", emoji="⏯️", style=discord.ButtonStyle.primary) + async def pause_resume(self, interaction: discord.Interaction, _: discord.ui.Button) -> None: + voice = self.player.voice_client(self.guild_id) + if voice and voice.is_playing(): + operation = voice.pause + elif voice and voice.is_paused(): + operation = voice.resume + else: + await interaction.response.send_message( + "Nic nie jest obecnie odtwarzane.", ephemeral=True + ) + return + await self.action(interaction, operation) + + @discord.ui.button(label="Pomiń", emoji="⏭️", style=discord.ButtonStyle.secondary) + async def skip(self, interaction: discord.Interaction, _: discord.ui.Button) -> None: + if not self.player.is_active(self.guild_id): + await interaction.response.send_message( + "Nic nie jest obecnie odtwarzane.", ephemeral=True + ) + return + await self.action(interaction, lambda: self.player.skip(self.guild_id)) + + @discord.ui.button(label="Stop", emoji="⏹️", style=discord.ButtonStyle.danger) + async def stop(self, interaction: discord.Interaction, _: discord.ui.Button) -> None: + async def operation() -> None: + self.player.save_queue(self.guild_id) + voice = self.player.voice_client(self.guild_id) + if voice and self.player.is_active(self.guild_id): + voice.stop() + self.player.clear(self.guild_id) + + await self.action(interaction, operation) + + @discord.ui.button(label="Pętla", emoji="🔁", style=discord.ButtonStyle.secondary) + async def loop(self, interaction: discord.Interaction, _: discord.ui.Button) -> None: + await self.action( + interaction, + lambda: self.player.set_loop_mode( + self.guild_id, self.player.cycle_loop_mode(self.guild_id) + ), + ) + + @discord.ui.button(label="Odśwież", emoji="🔄", style=discord.ButtonStyle.secondary) + async def refresh_button(self, interaction: discord.Interaction, _: discord.ui.Button) -> None: + await interaction.response.edit_message( + embed=player_embed(self.player, interaction.guild), view=self + ) + + @discord.ui.button( + label="Przywróć kolejkę", emoji="↩️", style=discord.ButtonStyle.success, row=1 + ) + async def restore(self, interaction: discord.Interaction, _: discord.ui.Button) -> None: + count = await self.player.restore_queue(self.guild_id) + if not count: + await interaction.response.send_message( + "Nie ma zapisanej kolejki dla tego serwera.", ephemeral=True + ) + return + await self.player.trigger_state_change() + await interaction.response.edit_message( + embed=player_embed(self.player, interaction.guild), view=self + ) + + @discord.ui.button(label="Rozłącz", emoji="🔌", style=discord.ButtonStyle.danger, row=1) + async def disconnect(self, interaction: discord.Interaction, _: discord.ui.Button) -> None: + voice = self.player.voice_client(self.guild_id) + if voice is None: + await interaction.response.send_message( + "Bot nie jest połączony z kanałem głosowym.", ephemeral=True + ) + return + self.player.save_queue(self.guild_id) + await voice.disconnect() + self.player.clear(self.guild_id) + await self.player.trigger_state_change() + await interaction.response.edit_message( + embed=player_embed(self.player, interaction.guild), view=self + ) + + +class PlayerCog(MusicCogBase, name="Player"): + """Publiczny panel sterowania odtwarzaczem na Discordzie.""" + + @app_commands.command(name="player", description="Otwórz panel sterowania odtwarzaczem") + async def player_command(self, interaction: discord.Interaction) -> None: + await interaction.response.send_message( + embed=player_embed(self.player, interaction.guild), + view=PlayerView(self.player, interaction.guild), + ) diff --git a/zyrarbot/ui/bot/cogs/queue_cog.py b/zyrarbot/ui/bot/cogs/queue_cog.py index 3e3bfb4..c6632dd 100644 --- a/zyrarbot/ui/bot/cogs/queue_cog.py +++ b/zyrarbot/ui/bot/cogs/queue_cog.py @@ -20,6 +20,19 @@ class QueueCog(MusicCogBase, name="Queue"): super().__init__(bot, player) self._settings = settings + async def _queue_autocomplete( + self, interaction: discord.Interaction, current: str + ) -> list[app_commands.Choice[str]]: + tracks = self.player.state(interaction.guild.id).queue + query = current.lower() + return [ + app_commands.Choice( + name=f"{position}. {track.title or 'Nieznany tytuł'}"[:100], value=track.id + ) + for position, track in enumerate(tracks, start=1) + if not query or query in (track.title or "").lower() + ][:AUTOCOMPLETE_LIMIT] + @app_commands.command(name="queue", description="Pokaż aktualną kolejkę") async def show_queue(self, interaction: discord.Interaction) -> None: state = self.player.state(interaction.guild.id) @@ -43,7 +56,13 @@ class QueueCog(MusicCogBase, name="Queue"): preview = "\n".join(lines[:QUEUE_PREVIEW_LIMIT]) if len(lines) > QUEUE_PREVIEW_LIMIT: preview += f"\n... i {len(lines) - QUEUE_PREVIEW_LIMIT} więcej." - await interaction.response.send_message(f"Aktualna kolejka:\n{preview}") + embed = discord.Embed( + title="🎶 Aktualna kolejka", + description=preview, + color=discord.Color.blurple(), + ) + embed.set_footer(text=f"{len(state.queue)} utworów w kolejce") + await interaction.response.send_message(embed=embed) async def _history_autocomplete( self, interaction: discord.Interaction, current: str @@ -69,21 +88,62 @@ class QueueCog(MusicCogBase, name="Queue"): f"{position + 1}. {discord.utils.escape_markdown(entry.title)}" for position, entry in enumerate(entries[:HISTORY_PREVIEW_LIMIT]) ) - await interaction.response.send_message( - f"📜 Historia odtwarzania (ostatnie {HISTORY_PREVIEW_LIMIT}):\n{listing}" + embed = discord.Embed( + title="📜 Historia odtwarzania", + description=listing, + color=discord.Color.gold(), ) + embed.set_footer(text=f"Ostatnie {min(len(entries), HISTORY_PREVIEW_LIMIT)} utworów") + await interaction.response.send_message(embed=embed) + + @app_commands.command(name="restorequeue", description="Przywróć ostatnią zapisaną kolejkę") + async def restore_queue(self, interaction: discord.Interaction) -> None: + count = await self.player.restore_queue(interaction.guild.id) + if not count: + await interaction.response.send_message( + "Nie ma zapisanej kolejki dla tego serwera.", ephemeral=True + ) + return + await interaction.response.send_message(f"✅ Przywrócono kolejkę ({count} utworów).") + + @app_commands.command(name="remove", description="Usuń wybrany utwór z kolejki") + @app_commands.describe(track_id="Utwór z kolejki") + @app_commands.autocomplete(track_id=_queue_autocomplete) + async def remove(self, interaction: discord.Interaction, track_id: str) -> None: + if not await self.player.remove(interaction.guild.id, track_id): + await interaction.response.send_message( + "Nie znaleziono utworu w kolejce.", ephemeral=True + ) + return + await self.player.trigger_state_change() + await interaction.response.send_message("✅ Usunięto utwór z kolejki.") + + @app_commands.command(name="jump", description="Odtwórz wybrany utwór z kolejki") + @app_commands.describe(track_id="Utwór z kolejki") + @app_commands.autocomplete(track_id=_queue_autocomplete) + async def jump(self, interaction: discord.Interaction, track_id: str) -> None: + position = self.player.state(interaction.guild.id).position_of(track_id) + if position < 0: + await interaction.response.send_message( + "Nie znaleziono utworu w kolejce.", ephemeral=True + ) + return + await self.player.jump(interaction.guild.id, position) + await self.player.trigger_state_change() + await interaction.response.send_message("▶️ Rozpoczęto wybrany utwór.") @app_commands.command( name="trackplays", description="Zmień liczbę odtworzeń dla utworu w kolejce" ) - @app_commands.describe(index="Pozycja w kolejce (od 1)", count="Nowa liczba odtworzeń (1-999)") + @app_commands.describe(track_id="Utwór z kolejki", count="Nowa liczba odtworzeń (1-999)") + @app_commands.autocomplete(track_id=_queue_autocomplete) async def track_plays_command( - self, interaction: discord.Interaction, index: int, count: int + self, interaction: discord.Interaction, track_id: str, count: int ) -> None: - queue = self.player.state(interaction.guild.id).queue - if not 1 <= index <= len(queue): + track = self.player.state(interaction.guild.id).find(track_id) + if track is None: await interaction.response.send_message( - "❌ Nieprawidłowy numer utworu w kolejce.", ephemeral=True + "❌ Nie znaleziono utworu w kolejce.", ephemeral=True ) return if not self._settings.is_valid_plays(count): @@ -91,7 +151,6 @@ class QueueCog(MusicCogBase, name="Queue"): "❌ Liczba odtworzeń musi być z przedziału 1-999.", ephemeral=True ) return - track = queue[index - 1] track.plays_left = count await self.player.trigger_state_change() title = discord.utils.escape_markdown(track.title or "") diff --git a/zyrarbot/ui/bot/cogs/voice_cog.py b/zyrarbot/ui/bot/cogs/voice_cog.py index a99f0e8..f8db215 100644 --- a/zyrarbot/ui/bot/cogs/voice_cog.py +++ b/zyrarbot/ui/bot/cogs/voice_cog.py @@ -3,6 +3,7 @@ from __future__ import annotations import discord from discord.ext import commands +from zyrarbot.music.notification_channel_resolver import NotificationChannelResolver from zyrarbot.ui.bot.cogs.common import MusicCogBase @@ -27,6 +28,37 @@ class VoiceCog(MusicCogBase, name="Voice"): self.player.clear(before.channel.guild.id) await self.player.trigger_state_change() + @discord.app_commands.command(name="connect", description="Połącz bota z kanałem głosowym") + @discord.app_commands.describe(channel="Kanał głosowy") + async def connect( + self, interaction: discord.Interaction, channel: discord.VoiceChannel + ) -> None: + existing = self.player.voice_client(interaction.guild.id) + if existing: + await existing.disconnect() + await channel.connect() + if not self.player.notification_channel(interaction.guild.id): + self.player.set_notification_channel( + interaction.guild.id, NotificationChannelResolver.first_writable(interaction.guild) + ) + await self.player.trigger_state_change() + await interaction.response.send_message(f"✅ Połączono z kanałem **{channel.name}**.") + + @discord.app_commands.command(name="notificationchannel", description="Ustaw kanał logów") + @discord.app_commands.describe(channel="Kanał tekstowy") + async def notification_channel( + self, interaction: discord.Interaction, channel: discord.TextChannel + ) -> None: + if channel not in NotificationChannelResolver.writable_channels(interaction.guild): + await interaction.response.send_message( + "Bot nie może pisać na tym kanale.", ephemeral=True + ) + return + self.player.set_notification_channel(interaction.guild.id, channel) + self.bot.config.set_notification_channel_id(interaction.guild.id, channel.id) + await self.player.trigger_state_change() + await interaction.response.send_message(f"✅ Kanał logów ustawiony na {channel.mention}.") + @discord.app_commands.command(name="leave", description="Odłącz bota od kanału głosowego") async def leave(self, interaction: discord.Interaction) -> None: if interaction.guild.voice_client is None: