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.storage.uploads_repository import UploadsRepository
|
||||||
from zyrarbot.ui.bot.cogs.control_cog import ControlCog
|
from zyrarbot.ui.bot.cogs.control_cog import ControlCog
|
||||||
from zyrarbot.ui.bot.cogs.playback_cog import PlaybackCog
|
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.queue_cog import QueueCog
|
||||||
from zyrarbot.ui.bot.cogs.settings_cog import SettingsCog
|
from zyrarbot.ui.bot.cogs.settings_cog import SettingsCog
|
||||||
from zyrarbot.ui.bot.cogs.voice_cog import VoiceCog
|
from zyrarbot.ui.bot.cogs.voice_cog import VoiceCog
|
||||||
@@ -53,6 +54,7 @@ class MusicBot(commands.Bot):
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
PlaybackCog(self, self.player, self.tracks, self.uploads),
|
PlaybackCog(self, self.player, self.tracks, self.uploads),
|
||||||
|
PlayerCog(self, self.player),
|
||||||
QueueCog(self, self.player, self.settings),
|
QueueCog(self, self.player, self.settings),
|
||||||
ControlCog(self, self.player),
|
ControlCog(self, self.player),
|
||||||
VoiceCog(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)
|
super().__init__(bot, player)
|
||||||
self._settings = settings
|
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ę")
|
@app_commands.command(name="queue", description="Pokaż aktualną kolejkę")
|
||||||
async def show_queue(self, interaction: discord.Interaction) -> None:
|
async def show_queue(self, interaction: discord.Interaction) -> None:
|
||||||
state = self.player.state(interaction.guild.id)
|
state = self.player.state(interaction.guild.id)
|
||||||
@@ -43,7 +56,13 @@ class QueueCog(MusicCogBase, name="Queue"):
|
|||||||
preview = "\n".join(lines[:QUEUE_PREVIEW_LIMIT])
|
preview = "\n".join(lines[:QUEUE_PREVIEW_LIMIT])
|
||||||
if len(lines) > QUEUE_PREVIEW_LIMIT:
|
if len(lines) > QUEUE_PREVIEW_LIMIT:
|
||||||
preview += f"\n... i {len(lines) - QUEUE_PREVIEW_LIMIT} więcej."
|
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(
|
async def _history_autocomplete(
|
||||||
self, interaction: discord.Interaction, current: str
|
self, interaction: discord.Interaction, current: str
|
||||||
@@ -69,21 +88,62 @@ class QueueCog(MusicCogBase, name="Queue"):
|
|||||||
f"{position + 1}. {discord.utils.escape_markdown(entry.title)}"
|
f"{position + 1}. {discord.utils.escape_markdown(entry.title)}"
|
||||||
for position, entry in enumerate(entries[:HISTORY_PREVIEW_LIMIT])
|
for position, entry in enumerate(entries[:HISTORY_PREVIEW_LIMIT])
|
||||||
)
|
)
|
||||||
await interaction.response.send_message(
|
embed = discord.Embed(
|
||||||
f"📜 Historia odtwarzania (ostatnie {HISTORY_PREVIEW_LIMIT}):\n{listing}"
|
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(
|
@app_commands.command(
|
||||||
name="trackplays", description="Zmień liczbę odtworzeń dla utworu w kolejce"
|
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(
|
async def track_plays_command(
|
||||||
self, interaction: discord.Interaction, index: int, count: int
|
self, interaction: discord.Interaction, track_id: str, count: int
|
||||||
) -> None:
|
) -> None:
|
||||||
queue = self.player.state(interaction.guild.id).queue
|
track = self.player.state(interaction.guild.id).find(track_id)
|
||||||
if not 1 <= index <= len(queue):
|
if track is None:
|
||||||
await interaction.response.send_message(
|
await interaction.response.send_message(
|
||||||
"❌ Nieprawidłowy numer utworu w kolejce.", ephemeral=True
|
"❌ Nie znaleziono utworu w kolejce.", ephemeral=True
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
if not self._settings.is_valid_plays(count):
|
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
|
"❌ Liczba odtworzeń musi być z przedziału 1-999.", ephemeral=True
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
track = queue[index - 1]
|
|
||||||
track.plays_left = count
|
track.plays_left = count
|
||||||
await self.player.trigger_state_change()
|
await self.player.trigger_state_change()
|
||||||
title = discord.utils.escape_markdown(track.title or "")
|
title = discord.utils.escape_markdown(track.title or "")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
import discord
|
import discord
|
||||||
from discord.ext import commands
|
from discord.ext import commands
|
||||||
|
|
||||||
|
from zyrarbot.music.notification_channel_resolver import NotificationChannelResolver
|
||||||
from zyrarbot.ui.bot.cogs.common import MusicCogBase
|
from zyrarbot.ui.bot.cogs.common import MusicCogBase
|
||||||
|
|
||||||
|
|
||||||
@@ -27,6 +28,37 @@ class VoiceCog(MusicCogBase, name="Voice"):
|
|||||||
self.player.clear(before.channel.guild.id)
|
self.player.clear(before.channel.guild.id)
|
||||||
await self.player.trigger_state_change()
|
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")
|
@discord.app_commands.command(name="leave", description="Odłącz bota od kanału głosowego")
|
||||||
async def leave(self, interaction: discord.Interaction) -> None:
|
async def leave(self, interaction: discord.Interaction) -> None:
|
||||||
if interaction.guild.voice_client is None:
|
if interaction.guild.voice_client is None:
|
||||||
|
|||||||
Reference in new issue
Block a user