feat: add interactive Discord player panel

This commit is contained in:
mmacierzanka committed 2026-08-21 17:35:48 +02:00
1 parent 90189b3f97
commit d287a8c8f2
4 files changed
+257 -9

No files matched your search

+2
View File
@@ -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),
+155
View File
@@ -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),
)
+68 -9
View File
@@ -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 "")
+32
View File
@@ -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: