feat: przebudowa systemu kolejki od podstaw z użyciem wskaźnika indeksu (nieusuwalna kolejka z możliwością przeskoku i przewijania)

This commit is contained in:
mmacierzanka committed 2026-07-04 16:53:20 +02:00
1 parent ab6f16b57d
commit a900097134
4 files changed
+268 -175

No files matched your search

+146 -87
View File
@@ -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 self.jump_flag.get(guild_id, False):
self.jump_flag[guild_id] = False
advance = False
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)
queue = self.get_queue(guild_id)
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:
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)
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 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),
self.play_next(guild_id, advance=True),
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}")
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
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()
)
)
await self.send_to_last_channel(guild_id, f"Teraz odtwarzane: {discord.utils.escape_markdown(next_player.title)}")
else:
self.set_current(guild_id, None)
await self.trigger_state_change()
await self.send_to_last_channel(guild_id, "Kolejka jest pusta.")
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
+32 -4
View File
@@ -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 = `<div class="queue-index">${idx + 1}</div>`;
if (idx === queueIndex) {
classes += ' playing-item';
indexHtml = `<div class="queue-index" title="Aktualnie odtwarzane"><i data-lucide="volume-2" style="color: var(--primary);"></i></div>`;
} 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) {
<div class="drag-handle" title="Przeciągnij, aby zmienić kolejność">
<i data-lucide="grip-vertical"></i>
</div>
<div class="queue-index">${idx + 1}</div>
${indexHtml}
<div class="queue-track-info">
<div class="queue-track-title" title="${track.title}">${track.title}</div>
</div>
<div class="queue-item-actions">
<button class="btn-action-icon" onclick="jumpToTrack(${idx})" title="Odtwarzaj od tej pozycji">
<i data-lucide="play"></i>
</button>
<button class="btn-action-icon" onclick="moveItemUp(${idx})" title="Przesuń w górę">
<i data-lucide="arrow-up"></i>
</button>
@@ -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) {
+17
View File
@@ -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);
+73 -84
View File
@@ -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)
res = await music_cog.enqueue_track(guild_id, player, from_web=True)
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}
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)
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)
if len(new_queue) < initial_len:
await broadcast_status()
return {"status": "removed"}
raise HTTPException(status_code=404, detail="Track not found in queue")
@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)
res = await music_cog.enqueue_track(guild_id, player, from_web=True)
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}
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"}