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 = `
${idx + 1}
`; + + if (idx === queueIndex) { + classes += ' playing-item'; + indexHtml = `
`; + } else if (idx < queueIndex) { + classes += ' played-item'; + } + + li.className = classes; li.setAttribute('draggable', 'true'); li.setAttribute('data-id', track.id); li.setAttribute('data-index', idx); @@ -443,11 +453,14 @@ function renderQueue(queue) {
-
${idx + 1}
+ ${indexHtml}
${track.title}
+ @@ -639,6 +652,21 @@ async function removeTrack(id) { } } +// Jump to specific track index in queue +async function jumpToTrack(idx) { + try { + const res = await fetch('/api/jump', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ index: idx }) + }); + if (!res.ok) throw new Error('Jump failed'); + showToast('Zmieniono odtwarzany utwór', 'info'); + } catch (err) { + showToast('Nie udało się zmienić utworu', 'error'); + } +} + // Toggle Connection async function toggleConnection() { if (currentStatus && currentStatus.active_voice_channel) { diff --git a/static/style.css b/static/style.css index 3b09d88..bee5e63 100644 --- a/static/style.css +++ b/static/style.css @@ -776,6 +776,23 @@ body { background: rgba(139, 92, 246, 0.08); } +/* Playing state */ +.queue-item.playing-item { + background: rgba(139, 92, 246, 0.15); + border-color: var(--primary); + box-shadow: 0 0 15px var(--primary-glow); +} + +.queue-item.playing-item .queue-track-title { + color: var(--primary-light); + font-weight: 600; +} + +/* Played state */ +.queue-item.played-item { + opacity: 0.55; +} + .drag-handle { cursor: grab; color: var(--text-muted); diff --git a/web.py b/web.py index d75d40f..b68e6cc 100644 --- a/web.py +++ b/web.py @@ -30,6 +30,9 @@ class RemoveRequest(BaseModel): class ReorderRequest(BaseModel): queue_ids: List[str] +class JumpRequest(BaseModel): + index: int + class ConnectRequest(BaseModel): channel_id: str @@ -178,6 +181,7 @@ async def get_status(): "id": item.id } for item in queue ], + "queue_index": music_cog.get_queue_index(guild_id), "voice_channels": all_voice_channels, "active_voice_channel": active_vc, "text_channels": text_channels, @@ -243,45 +247,9 @@ async def api_play(req: PlayRequest): else: player = await YTDLSource.from_url(req.query, loop=bot.loop, stream=True) - 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 voice_client.is_playing() or voice_client.is_paused(): - music_cog.get_queue(guild_id).append(player) - embed = discord.Embed( - title=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() - ) - embed.set_author(name="Dodano do kolejki z panelu Web", 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) - await music_cog.send_to_last_channel(guild_id, embed=embed) - await broadcast_status() - return {"status": "queued", "title": player.title} - else: - music_cog.set_current(guild_id, player) - music_cog.add_to_history(guild_id, player) - voice_client.play( - player, - after=lambda e: asyncio.run_coroutine_threadsafe( - music_cog.play_next(guild_id), - bot.loop - ) - ) - embed = discord.Embed( - title=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() - ) - embed.set_author(name="Teraz odtwarzane (z panelu Web)", icon_url="https://cdn-icons-png.flaticon.com/512/3844/3844724.png") - await music_cog.send_to_last_channel(guild_id, embed=embed) - await broadcast_status() - return {"status": "playing", "title": player.title} + res = await music_cog.enqueue_track(guild_id, player, from_web=True) + await broadcast_status() + return res except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @@ -328,6 +296,34 @@ async def api_skip(): return {"status": "skipped"} raise HTTPException(status_code=400, detail="Bot is not playing anything on this server") +@app.post("/api/jump") +async def api_jump(req: JumpRequest): + music_cog = bot.get_cog("MusicBot") + if not music_cog: + raise HTTPException(status_code=500, detail="MusicBot cog not loaded") + guild = get_active_guild() + if not guild: + raise HTTPException(status_code=400, detail="Bot has no active server context") + guild_id = guild.id + queue = music_cog.get_queue(guild_id) + if req.index < 0 or req.index >= len(queue): + raise HTTPException(status_code=400, detail="Invalid track index") + + if music_cog.get_loop_current(guild_id): + music_cog.set_loop_current(guild_id, False) + + music_cog.set_queue_index(guild_id, req.index) + music_cog.jump_flag[guild_id] = True + + voice_client = discord.utils.get(bot.voice_clients, guild__id=guild_id) + if voice_client and (voice_client.is_playing() or voice_client.is_paused()): + voice_client.stop() + else: + await music_cog.play_next(guild_id, advance=False) + + await broadcast_status() + return {"status": "jumped"} + @app.post("/api/stop") async def api_stop(): music_cog = bot.get_cog("MusicBot") @@ -343,8 +339,7 @@ async def api_stop(): if voice_client and (voice_client.is_playing() or voice_client.is_paused()): voice_client.stop() - music_cog.get_queue(guild_id).clear() - music_cog.set_current(guild_id, None) + music_cog.clear_queue(guild_id) await broadcast_status() return {"status": "stopped"} @@ -443,14 +438,35 @@ async def api_remove(req: RemoveRequest): guild_id = guild.id queue = music_cog.get_queue(guild_id) - initial_len = len(queue) - new_queue = [item for item in queue if item.id != req.id] - music_cog.queues[guild_id] = new_queue + current_idx = music_cog.get_queue_index(guild_id) - if len(new_queue) < initial_len: - await broadcast_status() - return {"status": "removed"} - raise HTTPException(status_code=404, detail="Track not found in queue") + removed_idx = -1 + for idx, item in enumerate(queue): + if item.id == req.id: + removed_idx = idx + break + + if removed_idx == -1: + raise HTTPException(status_code=404, detail="Track not found in queue") + + if removed_idx == current_idx: + new_queue = [item for idx, item in enumerate(queue) if idx != removed_idx] + music_cog.queues[guild_id] = new_queue + music_cog.set_queue_index(guild_id, current_idx) + music_cog.jump_flag[guild_id] = True + voice_client = discord.utils.get(bot.voice_clients, guild__id=guild_id) + if voice_client and (voice_client.is_playing() or voice_client.is_paused()): + voice_client.stop() + else: + await music_cog.play_next(guild_id, advance=False) + else: + new_queue = [item for idx, item in enumerate(queue) if idx != removed_idx] + music_cog.queues[guild_id] = new_queue + if removed_idx < current_idx: + music_cog.set_queue_index(guild_id, current_idx - 1) + + await broadcast_status() + return {"status": "removed"} @app.post("/api/reorder") async def api_reorder(req: ReorderRequest): @@ -464,6 +480,9 @@ async def api_reorder(req: ReorderRequest): guild_id = guild.id queue = music_cog.get_queue(guild_id) + current_idx = music_cog.get_queue_index(guild_id) + curr_item = queue[current_idx] if 0 <= current_idx < len(queue) else None + queue_map = {item.id: item for item in queue} new_queue = [] @@ -477,6 +496,12 @@ async def api_reorder(req: ReorderRequest): new_queue.append(item) music_cog.queues[guild_id] = new_queue + if curr_item: + try: + music_cog.set_queue_index(guild_id, new_queue.index(curr_item)) + except ValueError: + pass + await broadcast_status() return {"status": "success"} @@ -543,44 +568,9 @@ async def api_play_file(req: PlayFileRequest): try: player = await YTDLSource.from_file(filepath, loop=bot.loop) - title_clean = discord.utils.escape_markdown(player.title or "Nieznany tytuł") - - if voice_client.is_playing() or voice_client.is_paused(): - music_cog.get_queue(guild_id).append(player) - embed = discord.Embed( - title=f"📁 {title_clean}", - description="Plik lokalny z serwerowej bazy plików", - color=discord.Color.blue() - ) - embed.set_author(name="Dodano do kolejki z panelu Web", 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) - await music_cog.send_to_last_channel(guild_id, embed=embed) - await broadcast_status() - return {"status": "queued", "title": player.title} - else: - music_cog.set_current(guild_id, player) - music_cog.add_to_history(guild_id, player) - voice_client.play( - player, - after=lambda e: asyncio.run_coroutine_threadsafe( - music_cog.play_next(guild_id), - bot.loop - ) - ) - embed = discord.Embed( - title=f"▶️ {title_clean}", - description="Plik lokalny z serwerowej bazy plików", - color=discord.Color.green() - ) - embed.set_author(name="Teraz odtwarzane (z panelu Web)", 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) - await music_cog.send_to_last_channel(guild_id, embed=embed) - await broadcast_status() - return {"status": "playing", "title": player.title} + res = await music_cog.enqueue_track(guild_id, player, from_web=True) + await broadcast_status() + return res except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @@ -628,8 +618,7 @@ async def api_disconnect(): music_cog = bot.get_cog("MusicBot") if music_cog: - music_cog.get_queue(guild_id).clear() - music_cog.set_current(guild_id, None) + music_cog.clear_queue(guild_id) await broadcast_status() return {"status": "disconnected"}