feat: add interactive Discord player panel
This commit is contained in:
4 files changed
+257
-9
No files matched your search
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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 "")
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user