diff --git a/bot.py b/bot.py index 1773eba..6404760 100644 --- a/bot.py +++ b/bot.py @@ -23,6 +23,8 @@ class MusicBot(commands.Cog): self.loop_playlists = {} # guild_id -> bool self.last_channels = {} # guild_id -> text channel self.history = {} # guild_id -> list of dicts + self.queue_indices = {} # guild_id -> int + self.jump_flag = {} # guild_id -> bool self.on_state_change_callback = None def load_history(self, guild_id: int): @@ -111,6 +113,67 @@ class MusicBot(commands.Cog): def set_loop_playlist(self, guild_id: int, loop: bool): self.loop_playlists[guild_id] = loop + def get_queue_index(self, guild_id: int): + return self.queue_indices.get(guild_id, -1) + + def set_queue_index(self, guild_id: int, index: int): + self.queue_indices[guild_id] = index + + def clear_queue(self, guild_id: int): + self.get_queue(guild_id).clear() + self.set_current(guild_id, None) + self.set_queue_index(guild_id, -1) + + async def get_fresh_player(self, track): + if track.data.get('is_local'): + return await YTDLSource.from_file(track.data['filepath'], loop=self.bot.loop, title=track.title) + else: + url = track.data.get('webpage_url') or track.url + return await YTDLSource.from_url(url, loop=self.bot.loop, stream=True) + + async def enqueue_track(self, guild_id: int, player, channel=None, from_web=False): + if channel: + self.last_channels[guild_id] = channel + + queue = self.get_queue(guild_id) + queue.append(player) + + voice_client = discord.utils.get(self.bot.voice_clients, guild__id=guild_id) + is_playing = voice_client and (voice_client.is_playing() or voice_client.is_paused()) + + url = player.data.get('webpage_url') or player.url + title_clean = discord.utils.escape_markdown(player.title or "Nieznany tytuł") + is_http = url and str(url).startswith("http") + + if is_playing: + embed = discord.Embed( + title=f"🎵 {title_clean}" if not player.data.get('is_local') else f"📁 {title_clean}", + url=url if is_http else None, + description=f"[Kliknij tutaj, aby otworzyć link]({url})" if is_http else "Plik lokalny / brak linku", + color=discord.Color.blue() + ) + if from_web: + embed.set_author(name="Dodano do kolejki z panelu Web", icon_url="https://cdn-icons-png.flaticon.com/512/3844/3844724.png") + else: + embed.set_author(name="Dodano do kolejki", icon_url="https://cdn-icons-png.flaticon.com/512/3844/3844724.png") + if player.duration: + mins, secs = divmod(int(player.duration), 60) + embed.add_field(name="Czas trwania", value=f"{mins}:{secs:02d}", inline=True) + embed.set_footer(text=f"Pozycja w kolejce: {len(queue)}") + await self.send_to_last_channel(guild_id, embed=embed) + await self.trigger_state_change() + return {"status": "queued", "title": player.title} + else: + await self.play_next(guild_id, advance=False) + return {"status": "playing", "title": player.title} + + @commands.Cog.listener() + async def on_voice_state_update(self, member, before, after): + if member.id == self.bot.user.id and before.channel is not None and after.channel is None: + guild_id = before.channel.guild.id + self.clear_queue(guild_id) + await self.trigger_state_change() + async def trigger_state_change(self): if self.on_state_change_callback: try: @@ -211,24 +274,13 @@ class MusicBot(commands.Cog): else: player = await YTDLSource.from_url(query, loop=self.bot.loop, stream=True) - if interaction.guild.voice_client.is_playing(): - self.get_queue(guild_id).append(player) - await self.trigger_state_change() + res = await self.enqueue_track(guild_id, player, channel=interaction.channel, from_web=False) + if res["status"] == "queued": await interaction.followup.send( f"Dodano do kolejki: {discord.utils.escape_markdown(player.title)}", ephemeral=False ) else: - self.set_current(guild_id, player) - self.add_to_history(guild_id, player) - await self.trigger_state_change() - interaction.guild.voice_client.play( - player, - after=lambda e: asyncio.run_coroutine_threadsafe( - self.play_next(guild_id), - self.bot.loop - ) - ) await interaction.followup.send( f"Teraz odtwarzane: {discord.utils.escape_markdown(player.title)}", ephemeral=False @@ -271,24 +323,13 @@ class MusicBot(commands.Cog): try: player = await YTDLSource.from_file(filepath, loop=self.bot.loop) - if interaction.guild.voice_client.is_playing(): - self.get_queue(guild_id).append(player) - await self.trigger_state_change() + res = await self.enqueue_track(guild_id, player, channel=interaction.channel, from_web=False) + if res["status"] == "queued": await interaction.followup.send( f"Dodano do kolejki: {discord.utils.escape_markdown(player.title)}", ephemeral=False ) else: - self.set_current(guild_id, player) - self.add_to_history(guild_id, player) - await self.trigger_state_change() - interaction.guild.voice_client.play( - player, - after=lambda e: asyncio.run_coroutine_threadsafe( - self.play_next(guild_id), - self.bot.loop - ) - ) await interaction.followup.send( f"Teraz odtwarzane: {discord.utils.escape_markdown(player.title)}", ephemeral=False @@ -299,65 +340,88 @@ class MusicBot(commands.Cog): ephemeral=True ) - async def play_next(self, guild_id: int): + async def play_next(self, guild_id: int, advance: bool = True): voice_client = discord.utils.get(self.bot.voice_clients, guild__id=guild_id) if not voice_client: self.set_current(guild_id, None) return - loop_current = self.get_loop_current(guild_id) - current = self.get_current(guild_id) - - if loop_current and current: - try: - if current.data.get('is_local'): - player = await YTDLSource.from_file(current.data['filepath'], loop=self.bot.loop, title=current.title) - else: - url = current.data.get('webpage_url') or current.url - player = await YTDLSource.from_url(url, loop=self.bot.loop, stream=True) - self.set_current(guild_id, player) - await self.trigger_state_change() - voice_client.play( - player, - after=lambda e: asyncio.run_coroutine_threadsafe( - self.play_next(guild_id), - self.bot.loop - ) - ) - await self.send_to_last_channel(guild_id, f"Teraz odtwarzane (zapętlone): {discord.utils.escape_markdown(player.title)}") - return - except Exception as e: - print(f"Error repeating song: {e}") - elif self.get_loop_playlist(guild_id) and current: - try: - if current.data.get('is_local'): - new_player = await YTDLSource.from_file(current.data['filepath'], loop=self.bot.loop, title=current.title) - else: - url = current.data.get('webpage_url') or current.url - new_player = await YTDLSource.from_url(url, loop=self.bot.loop, stream=True) - self.get_queue(guild_id).append(new_player) - await self.trigger_state_change() - except Exception as e: - print(f"Error re-adding track to playlist loop: {e}") + if self.jump_flag.get(guild_id, False): + self.jump_flag[guild_id] = False + advance = False queue = self.get_queue(guild_id) - if len(queue) > 0: - next_player = queue.pop(0) - self.set_current(guild_id, next_player) - self.add_to_history(guild_id, next_player) - await self.trigger_state_change() - voice_client.play( - next_player, - after=lambda e: asyncio.run_coroutine_threadsafe( - self.play_next(guild_id), - self.bot.loop - ) - ) - await self.send_to_last_channel(guild_id, f"Teraz odtwarzane: {discord.utils.escape_markdown(next_player.title)}") + if len(queue) == 0: + self.set_queue_index(guild_id, -1) + self.set_current(guild_id, None) + return + + idx = self.get_queue_index(guild_id) + if idx == -1 and len(queue) > 0: + idx = 0 + + if not advance: + if 0 <= idx < len(queue) and self.get_current(guild_id) is not None: + next_idx = idx + else: + next_idx = len(queue) - 1 else: + if self.get_loop_current(guild_id): + next_idx = idx + elif self.get_loop_playlist(guild_id): + next_idx = (idx + 1) % len(queue) + else: + next_idx = idx + 1 + + if next_idx >= len(queue): + self.set_queue_index(guild_id, next_idx) self.set_current(guild_id, None) await self.trigger_state_change() - await self.send_to_last_channel(guild_id, "Kolejka jest pusta.") + await self.send_to_last_channel(guild_id, "Kolejka zakończona.") + return + + self.set_queue_index(guild_id, next_idx) + track = queue[next_idx] + + if getattr(track, 'played', False): + try: + player = await self.get_fresh_player(track) + except Exception as e: + print(f"Error getting fresh player for track {track.title}: {e}") + await self.send_to_last_channel(guild_id, f"⚠️ Nie udało się odtworzyć: {track.title} ({e}). Pomijam.") + return await self.play_next(guild_id, advance=True) + else: + player = track + + player.played = True + queue[next_idx] = player + self.set_current(guild_id, player) + self.add_to_history(guild_id, player) + await self.trigger_state_change() + + voice_client.play( + player, + after=lambda e: asyncio.run_coroutine_threadsafe( + self.play_next(guild_id, advance=True), + self.bot.loop + ) + ) + + url = player.data.get('webpage_url') or player.url + title_clean = discord.utils.escape_markdown(player.title or "Nieznany tytuł") + is_http = url and str(url).startswith("http") + + embed = discord.Embed( + title=f"▶️ {title_clean}" if not player.data.get('is_local') else f"📁 {title_clean}", + url=url if is_http else None, + description=f"[Kliknij tutaj, aby otworzyć link]({url})" if is_http else "Plik lokalny / brak linku", + color=discord.Color.green() + ) + if player.duration: + mins, secs = divmod(int(player.duration), 60) + embed.add_field(name="Czas trwania", value=f"{mins}:{secs:02d}", inline=True) + embed.set_footer(text=f"Pozycja w kolejce: {next_idx + 1} / {len(queue)}") + await self.send_to_last_channel(guild_id, embed=embed) async def send_to_last_channel(self, guild_id: int, text: str = None, embed: discord.Embed = None): channel = self.get_notification_channel(guild_id) @@ -379,7 +443,15 @@ class MusicBot(commands.Cog): ) return - queue_list = "\n".join([f"{i + 1}. {discord.utils.escape_markdown(item.title)}" for i, item in enumerate(queue)]) + lines = [] + current_idx = self.get_queue_index(guild_id) + for i, item in enumerate(queue): + prefix = "▶️ **" if i == current_idx else f"{i + 1}. " + suffix = "** (obecnie odtwarzane)" if i == current_idx else "" + lines.append(f"{prefix}{discord.utils.escape_markdown(item.title)}{suffix}") + queue_list = "\n".join(lines[:30]) + if len(lines) > 30: + queue_list += f"\n... i {len(lines) - 30} więcej." await interaction.response.send_message( f"Aktualna kolejka:\n{queue_list}", ephemeral=False @@ -448,24 +520,13 @@ class MusicBot(commands.Cog): else: player = await YTDLSource.from_url(entry["url"], loop=self.bot.loop, stream=True) - if interaction.guild.voice_client.is_playing(): - self.get_queue(guild_id).append(player) - await self.trigger_state_change() + res = await self.enqueue_track(guild_id, player, channel=interaction.channel, from_web=False) + if res["status"] == "queued": await interaction.followup.send( f"Dodano do kolejki z historii: {discord.utils.escape_markdown(player.title)}", ephemeral=False ) else: - self.set_current(guild_id, player) - self.add_to_history(guild_id, player) - await self.trigger_state_change() - interaction.guild.voice_client.play( - player, - after=lambda e: asyncio.run_coroutine_threadsafe( - self.play_next(guild_id), - self.bot.loop - ) - ) await interaction.followup.send( f"Teraz odtwarzane z historii: {discord.utils.escape_markdown(player.title)}", ephemeral=False @@ -530,8 +591,7 @@ class MusicBot(commands.Cog): guild_id = interaction.guild.id if interaction.guild.voice_client.is_playing(): interaction.guild.voice_client.stop() - self.get_queue(guild_id).clear() - self.set_current(guild_id, None) + self.clear_queue(guild_id) await self.trigger_state_change() await interaction.response.send_message( "Zatrzymano odtwarzanie i wyczyszczono kolejkę.", @@ -587,8 +647,7 @@ class MusicBot(commands.Cog): guild_id = interaction.guild.id if interaction.guild.voice_client is not None: await interaction.guild.voice_client.disconnect() - self.get_queue(guild_id).clear() - self.set_current(guild_id, None) + self.clear_queue(guild_id) await interaction.response.send_message( "Rozłączono z kanału głosowego.", ephemeral=False diff --git a/static/app.js b/static/app.js index c8772b6..963abe7 100644 --- a/static/app.js +++ b/static/app.js @@ -409,7 +409,7 @@ function updateUI(status) { // 6. Queue render (if not currently dragging) if (!isDragging) { - renderQueue(status.queue); + renderQueue(status.queue, status.queue_index); renderHistory(status.history || []); } @@ -418,7 +418,7 @@ function updateUI(status) { } // Render Queue Items list -function renderQueue(queue) { +function renderQueue(queue, queueIndex = -1) { // Clear list but keep empty queue message const items = queueList.querySelectorAll('.queue-item'); items.forEach(el => el.remove()); @@ -434,7 +434,17 @@ function renderQueue(queue) { queue.forEach((track, idx) => { const li = document.createElement('li'); - li.className = 'queue-item'; + let classes = 'queue-item'; + let indexHtml = `