Files
zyrarbot/static/app.js
T

1196 lines
45 KiB
JavaScript

// Global variables
let isDragging = false;
let currentStatus = null;
let currentVolume = 100;
let isUpdatingVolume = false;
// Progress Bar Variables
let localElapsed = 0;
let totalDuration = 0;
let isPlaying = false;
// WebSocket Variables
let ws = null;
let wsConnectedOnce = false;
// DOM Elements
const statusDot = document.getElementById('status-dot');
const statusText = document.getElementById('status-text');
const voiceSelect = document.getElementById('voice-channel-select');
const btnOpenSettings = document.getElementById('btn-open-settings');
const btnCloseSettings = document.getElementById('btn-close-settings');
const settingsModal = document.getElementById('settings-modal');
const settingsGuildsList = document.getElementById('settings-guilds-list');
const btnConnect = document.getElementById('btn-connect');
const trackTitle = document.getElementById('track-title');
const trackArtist = document.getElementById('track-artist');
const trackLink = document.getElementById('track-link');
const loopBadge = document.getElementById('loop-badge');
const btnPlayPause = document.getElementById('btn-play-pause');
const playPauseIcon = document.getElementById('play-pause-icon');
const btnSkip = document.getElementById('btn-skip');
const btnStop = document.getElementById('btn-stop');
const btnLoop = document.getElementById('btn-loop');
const vinylRecord = document.getElementById('vinyl-record');
const musicWave = document.getElementById('music-wave');
const volumeSlider = document.getElementById('volume-slider');
const volumeIcon = document.getElementById('volume-icon');
const volumeValue = document.getElementById('volume-value');
const addForm = document.getElementById('add-track-form');
const btnAddTrack = document.getElementById('btn-add-track');
const btnAddText = document.getElementById('btn-add-text');
const btnAddSpinner = document.getElementById('btn-add-spinner');
const trackQueryInput = document.getElementById('track-query');
const queueList = document.getElementById('queue-list');
const emptyQueueMsg = document.getElementById('empty-queue-msg');
const queueCountBadge = document.getElementById('queue-count');
const toastContainer = document.getElementById('toast-container');
const fileInput = document.getElementById('file-input');
const btnSelectFile = document.getElementById('btn-select-file');
const uploadProgressContainer = document.getElementById('upload-progress-container');
const uploadProgressFill = document.getElementById('upload-progress-fill');
const uploadStatusText = document.getElementById('upload-status-text');
const uploadsList = document.getElementById('uploads-list');
const emptyUploadsMsg = document.getElementById('empty-uploads-msg');
const uploadsCountBadge = document.getElementById('uploads-count');
const btnOpenAllUploads = document.getElementById('btn-open-all-uploads');
const btnAllUploadsCount = document.getElementById('btn-all-uploads-count');
const uploadsModal = document.getElementById('uploads-modal');
const btnCloseUploads = document.getElementById('btn-close-uploads');
const modalUploadsList = document.getElementById('modal-uploads-list');
const emptyModalUploadsMsg = document.getElementById('empty-modal-uploads-msg');
const historyList = document.getElementById('history-list');
const emptyHistoryMsg = document.getElementById('empty-history-msg');
const historyCountBadge = document.getElementById('history-count');
// Progress Bar DOM Elements
const progressContainer = document.getElementById('progress-container');
const timeElapsed = document.getElementById('time-elapsed');
const timeTotal = document.getElementById('time-total');
const progressBarFill = document.getElementById('progress-bar-fill');
// Connect to WebSocket server for real-time events
function connectWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws`;
console.log(`Próba połączenia z WebSocket: ${wsUrl}`);
ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log('Połączono z WebSocket');
if (wsConnectedOnce) {
showToast('Połączenie WebSocket przywrócone (Live)!', 'success');
} else {
showToast('WebSocket zsynchronizowany (Live)!', 'success');
wsConnectedOnce = true;
}
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
// Only update UI if we are not currently dragging queue items
if (!isDragging) {
currentStatus = data;
updateUI(data);
}
} catch (err) {
console.error('Błąd parsowania danych z WS:', err);
}
};
ws.onclose = () => {
console.log('Połączenie WebSocket zamknięte. Próba ponownego połączenia...');
statusDot.className = 'status-indicator offline';
statusText.textContent = 'Rozłączono (brak połączenia z WS)';
// Reconnect after 3 seconds
setTimeout(connectWebSocket, 3000);
};
ws.onerror = (err) => {
console.error('Błąd WebSocket:', err);
ws.close();
};
}
// Initialize
document.addEventListener('DOMContentLoaded', () => {
// Connect to WebSocket instead of polling
connectWebSocket();
fetchUploads();
// Start Smooth Progress Tracker (every 100ms)
setInterval(() => {
if (isPlaying && currentStatus && currentStatus.current) {
localElapsed += 0.1;
if (totalDuration > 0 && localElapsed > totalDuration) {
localElapsed = totalDuration;
}
updateProgressBar();
}
}, 100);
// Bind Event Listeners
bindEvents();
});
// Bind UI Events
function bindEvents() {
// Connection Button
btnConnect.addEventListener('click', toggleConnection);
// Playback Controls
btnPlayPause.addEventListener('click', togglePlayPause);
btnSkip.addEventListener('click', skipTrack);
btnStop.addEventListener('click', stopPlayback);
btnLoop.addEventListener('click', toggleLoop);
// Volume slider
volumeSlider.addEventListener('input', (e) => {
isUpdatingVolume = true;
const val = e.target.value;
volumeValue.textContent = `${val}%`;
updateVolumeIcon(val);
});
volumeSlider.addEventListener('change', (e) => {
const val = parseInt(e.target.value);
setVolume(val);
setTimeout(() => {
isUpdatingVolume = false;
}, 500);
});
// Add Song Form
addForm.addEventListener('submit', addTrack);
// Settings Modal Events
if (btnOpenSettings) btnOpenSettings.addEventListener('click', openSettingsModal);
if (btnCloseSettings) btnCloseSettings.addEventListener('click', closeSettingsModal);
if (settingsModal) {
settingsModal.addEventListener('click', (e) => {
if (e.target === settingsModal) closeSettingsModal();
});
}
const btnSaveDefaultPlays = document.getElementById('btn-save-default-plays');
if (btnSaveDefaultPlays) {
btnSaveDefaultPlays.addEventListener('click', async () => {
const inputDefaultPlays = document.getElementById('setting-default-plays');
if (!inputDefaultPlays) return;
const val = parseInt(inputDefaultPlays.value, 10);
if (isNaN(val) || val < 1 || val > 999) {
showToast('Wartość musi być z przedziału 1-999', 'error');
return;
}
try {
const res = await fetch('/api/settings/default_plays', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ default_plays: val })
});
if (!res.ok) throw new Error('Failed to save setting');
showToast(`Domyślna liczba odtworzeń: ${val}`, 'success');
} catch (err) {
showToast('Błąd zapisu ustawienia', 'error');
}
});
}
// Uploads Modal Events
if (btnOpenAllUploads) btnOpenAllUploads.addEventListener('click', openUploadsModal);
if (btnCloseUploads) btnCloseUploads.addEventListener('click', closeUploadsModal);
if (uploadsModal) {
uploadsModal.addEventListener('click', (e) => {
if (e.target === uploadsModal) closeUploadsModal();
});
}
// Upload Events
if (btnSelectFile && fileInput) {
btnSelectFile.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', handleFileUpload);
}
}
// Show Toast Notification
function showToast(message, type = 'success') {
const toast = document.createElement('div');
toast.className = `toast ${type}`;
let icon = 'info';
if (type === 'success') icon = 'check-circle-2';
if (type === 'error') icon = 'alert-triangle';
toast.innerHTML = `<i data-lucide="${icon}"></i> <span>${message}</span>`;
toastContainer.appendChild(toast);
lucide.createIcons();
// Remove after 3.5 seconds
setTimeout(() => {
toast.style.transform = 'translateY(-20px)';
toast.style.opacity = '0';
setTimeout(() => {
toast.remove();
}, 300);
}, 3500);
}
// Update Volume Icon
function updateVolumeIcon(vol) {
vol = parseInt(vol);
if (vol === 0) {
volumeIcon.setAttribute('data-lucide', 'volume-x');
} else if (vol < 35) {
volumeIcon.setAttribute('data-lucide', 'volume');
} else if (vol < 75) {
volumeIcon.setAttribute('data-lucide', 'volume-1');
} else {
volumeIcon.setAttribute('data-lucide', 'volume-2');
}
lucide.createIcons();
}
// API Calls
async function fetchStatus() {
try {
const res = await fetch('/api/status');
if (!res.ok) throw new Error('API Error');
const data = await res.json();
currentStatus = data;
updateUI(data);
} catch (err) {
console.error('Błąd pobierania statusu:', err);
statusDot.className = 'status-indicator offline';
statusText.textContent = 'Błąd połączenia z API';
}
}
// Progress Bar Helpers
function formatTime(secs) {
if (secs === null || secs === undefined || isNaN(secs) || secs < 0) return "0:00";
const h = Math.floor(secs / 3600);
const m = Math.floor((secs % 3600) / 60);
const s = Math.floor(secs % 60);
const formattedSecs = s < 10 ? `0${s}` : s;
if (h > 0) {
const formattedMins = m < 10 ? `0${m}` : m;
return `${h}:${formattedMins}:${formattedSecs}`;
}
return `${m}:${formattedSecs}`;
}
function updateProgressBar() {
timeElapsed.textContent = formatTime(localElapsed);
if (totalDuration > 0) {
const pct = Math.min((localElapsed / totalDuration) * 100, 100);
progressBarFill.style.width = `${pct}%`;
}
}
// Update UI States
function updateUI(status) {
currentStatus = status;
// 1. Connection Status & Available Channels
if (status.active_voice_channel) {
statusDot.className = 'status-indicator online';
statusText.textContent = `Połączony: ${status.active_voice_channel.channel_name} (${status.active_voice_channel.guild_name})`;
btnConnect.innerHTML = '<i data-lucide="log-out"></i> Odłącz';
btnConnect.className = 'btn btn-danger';
} else {
statusDot.className = 'status-indicator offline';
statusText.textContent = 'Rozłączony';
btnConnect.innerHTML = '<i data-lucide="log-in"></i> Połącz';
btnConnect.className = 'btn btn-primary btn-glow';
}
// Voice selector population (only if select doesn't have focus/interaction)
if (document.activeElement !== voiceSelect) {
// Keep selected value
const currentSelected = voiceSelect.value;
// Clear
voiceSelect.innerHTML = '<option value="" disabled>Wybierz kanał...</option>';
if (status.voice_channels && status.voice_channels.length > 0) {
status.voice_channels.forEach(channel => {
const opt = document.createElement('option');
opt.value = channel.channel_id;
opt.textContent = `${channel.guild_name} - ${channel.channel_name}`;
voiceSelect.appendChild(opt);
});
// Re-select or select first/active
if (status.active_voice_channel) {
voiceSelect.value = status.active_voice_channel.channel_id;
} else if (currentSelected && status.voice_channels.some(c => c.channel_id === currentSelected)) {
voiceSelect.value = currentSelected;
} else {
voiceSelect.value = status.voice_channels[0].channel_id;
}
} else {
const opt = document.createElement('option');
opt.value = "";
opt.textContent = "Brak dostępnych kanałów";
voiceSelect.appendChild(opt);
}
}
// Settings Guilds Population
if (status.guilds) {
renderSettingsGuilds(status.guilds);
}
if (status.default_plays !== undefined) {
const inputDefaultPlays = document.getElementById('setting-default-plays');
if (inputDefaultPlays && document.activeElement !== inputDefaultPlays) {
inputDefaultPlays.value = status.default_plays;
}
}
// 2. Playback State
if (status.current) {
trackTitle.textContent = status.current.title;
trackArtist.textContent = `Wyszukano/URL: ${status.current.url ? 'YouTube Video' : 'Nieznany'}`;
trackLink.href = status.current.url || '#';
trackLink.style.display = status.current.url ? 'inline-flex' : 'none';
// Sync progress bar values
totalDuration = status.current.duration || 0;
localElapsed = status.current.elapsed || 0;
isPlaying = status.playing; // Update playing state
// Show progress bar
progressContainer.style.display = 'flex';
timeElapsed.textContent = formatTime(localElapsed);
if (totalDuration > 0) {
timeTotal.textContent = formatTime(totalDuration);
const pct = Math.min((localElapsed / totalDuration) * 100, 100);
progressBarFill.style.width = `${pct}%`;
} else {
timeTotal.textContent = "Live";
progressBarFill.style.width = "100%";
}
// Vinyl animation
if (status.playing) {
vinylRecord.classList.add('playing');
musicWave.classList.add('active');
} else {
vinylRecord.classList.remove('playing');
musicWave.classList.remove('active');
}
} else {
trackTitle.textContent = 'Nic nie jest odtwarzane';
trackArtist.textContent = 'Połącz bota i dodaj utwór';
trackLink.style.display = 'none';
vinylRecord.classList.remove('playing');
musicWave.classList.remove('active');
// Hide progress bar
progressContainer.style.display = 'none';
isPlaying = false;
localElapsed = 0;
totalDuration = 0;
}
// 3. Play/Pause Button State
const targetIcon = status.playing ? 'pause' : 'play';
if (btnPlayPause.dataset.state !== targetIcon) {
btnPlayPause.dataset.state = targetIcon;
btnPlayPause.innerHTML = `<i data-lucide="${targetIcon}"></i>`;
btnPlayPause.title = status.playing ? "Wstrzymaj" : "Odtwarzaj";
if (window.lucide) {
lucide.createIcons();
}
}
// 4. Volume State
if (!isUpdatingVolume) {
volumeSlider.value = status.volume;
volumeValue.textContent = `${status.volume}%`;
updateVolumeIcon(status.volume);
}
// 5. Loop State (Combined toggle: Off -> Playlist -> Single)
let loopMode = 'off';
if (status.loop_single) loopMode = 'single';
else if (status.loop_playlist) loopMode = 'playlist';
if (btnLoop.dataset.loopMode !== loopMode) {
btnLoop.dataset.loopMode = loopMode;
if (loopMode === 'single') {
btnLoop.innerHTML = '<i data-lucide="repeat-1"></i>';
btnLoop.classList.add('active');
btnLoop.style.background = 'var(--primary)';
btnLoop.style.color = 'var(--bg-card)';
btnLoop.title = "Zapętlenie: jeden utwór (kliknij, aby wyłączyć)";
loopBadge.style.display = 'flex';
loopBadge.innerHTML = '<i data-lucide="repeat-1"></i> Zapętlenie utworu';
} else if (loopMode === 'playlist') {
btnLoop.innerHTML = '<i data-lucide="repeat"></i>';
btnLoop.classList.add('active');
btnLoop.style.background = 'var(--primary)';
btnLoop.style.color = 'var(--bg-card)';
btnLoop.title = "Zapętlenie: cała kolejka (kliknij, aby zapętlić jeden utwór)";
loopBadge.style.display = 'flex';
loopBadge.innerHTML = '<i data-lucide="repeat"></i> Zapętlenie kolejki';
} else {
btnLoop.innerHTML = '<i data-lucide="repeat"></i>';
btnLoop.classList.remove('active');
btnLoop.style.background = '';
btnLoop.style.color = '';
btnLoop.title = "Zapętlenie: wyłączone (kliknij, aby zapętlić kolejkę)";
loopBadge.style.display = 'none';
}
if (window.lucide) {
lucide.createIcons();
}
}
// 6. Queue render (if not currently dragging and not editing track plays input)
if (!isDragging && (!document.activeElement || !document.activeElement.classList.contains('track-plays-input'))) {
renderQueue(status.queue, status.queue_index);
renderHistory(status.history || []);
}
// Re-trigger lucide icons update
lucide.createIcons();
}
// Render Queue Items list
function renderQueue(queue, queueIndex = -1) {
// Clear list but keep empty queue message
const items = queueList.querySelectorAll('.queue-item');
items.forEach(el => el.remove());
if (!queue || queue.length === 0) {
emptyQueueMsg.style.display = 'flex';
queueCountBadge.textContent = '0 utworów';
return;
}
emptyQueueMsg.style.display = 'none';
queueCountBadge.textContent = `${queue.length} ${queue.length === 1 ? 'utwór' : queue.length < 5 ? 'utwory' : 'utworów'}`;
queue.forEach((track, idx) => {
const li = document.createElement('li');
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);
li.innerHTML = `
<div class="drag-handle" title="Przeciągnij, aby zmienić kolejność">
<i data-lucide="grip-vertical"></i>
</div>
${indexHtml}
<div class="queue-track-info">
<div class="queue-track-title" title="${track.title}">${track.title}</div>
<div class="track-plays-control" style="display: flex; align-items: center; gap: 6px; font-size: 0.75rem; background: rgba(255,255,255,0.06); padding: 3px 10px; border-radius: 12px; margin-top: 6px; width: fit-content; border: 1px solid rgba(255,255,255,0.1);">
<i data-lucide="repeat" style="width: 13px; height: 13px; opacity: 0.9; color: var(--primary);"></i>
<span title="Pozostało odtworzeń z kolejki" style="color: var(--text-muted);">Pozostało:</span>
<button class="btn-tiny-action" onclick="changeTrackPlays('${track.id}', -1)" title="Zmniejsz o 1" style="background: rgba(255,255,255,0.12); border: none; color: inherit; cursor: pointer; padding: 2px 6px; border-radius: 6px; margin-left: 2px; transition: background 0.2s;"><i data-lucide="minus" style="width: 11px; height: 11px;"></i></button>
<input type="number" class="track-plays-input" min="1" max="999" value="${track.plays_left !== undefined ? track.plays_left : (currentStatus && currentStatus.default_plays ? currentStatus.default_plays : 5)}" onchange="changeTrackPlaysDirect('${track.id}', this.value)" style="width: 44px; text-align: center; background: rgba(0,0,0,0.35); border: 1px solid rgba(255,255,255,0.2); color: #fff; border-radius: 6px; padding: 2px 4px; font-size: 0.8rem; font-weight: bold;">
<button class="btn-tiny-action" onclick="changeTrackPlays('${track.id}', 1)" title="Zwiększ o 1" style="background: rgba(255,255,255,0.12); border: none; color: inherit; cursor: pointer; padding: 2px 6px; border-radius: 6px; transition: background 0.2s;"><i data-lucide="plus" style="width: 11px; height: 11px;"></i></button>
</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>
<button class="btn-action-icon" onclick="moveItemDown(${idx})" title="Przesuń w dół">
<i data-lucide="arrow-down"></i>
</button>
<button class="btn-action-icon delete" onclick="removeTrack('${track.id}')" title="Usuń z kolejki">
<i data-lucide="trash-2"></i>
</button>
</div>
`;
// Add Drag and Drop listeners
li.addEventListener('dragstart', handleDragStart);
li.addEventListener('dragenter', handleDragEnter);
li.addEventListener('dragover', handleDragOver);
li.addEventListener('dragleave', handleDragLeave);
li.addEventListener('drop', handleDrop);
li.addEventListener('dragend', handleDragEnd);
queueList.appendChild(li);
});
}
// Drag & Drop Handlers
let dragSrcEl = null;
function handleDragStart(e) {
this.classList.add('dragging');
isDragging = true;
dragSrcEl = this;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', this.getAttribute('data-id'));
}
function handleDragEnter(e) {
if (this !== dragSrcEl) {
this.classList.add('drag-over');
}
}
function handleDragOver(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
return false;
}
function handleDragLeave(e) {
this.classList.remove('drag-over');
}
function handleDrop(e) {
e.stopPropagation();
e.preventDefault();
this.classList.remove('drag-over');
if (dragSrcEl !== this) {
const listItems = Array.from(queueList.querySelectorAll('.queue-item'));
const fromIdx = parseInt(dragSrcEl.getAttribute('data-index'));
const toIdx = parseInt(this.getAttribute('data-index'));
// Local state swap for speed
const srcId = dragSrcEl.getAttribute('data-id');
const targetId = this.getAttribute('data-id');
// Re-arrange DOM locally for visual feedback
if (fromIdx < toIdx) {
this.parentNode.insertBefore(dragSrcEl, this.nextSibling);
} else {
this.parentNode.insertBefore(dragSrcEl, this);
}
// Recalculate index numbers locally
const updatedItems = Array.from(queueList.querySelectorAll('.queue-item'));
const newOrderIds = updatedItems.map(item => item.getAttribute('data-id'));
// Send to backend
sendReorder(newOrderIds);
}
return false;
}
function handleDragEnd(e) {
this.classList.remove('dragging');
const items = queueList.querySelectorAll('.queue-item');
items.forEach(item => {
item.classList.remove('drag-over');
});
isDragging = false;
}
// Manual Up/Down Buttons
function moveItemUp(idx) {
if (idx === 0) return;
const items = Array.from(queueList.querySelectorAll('.queue-item'));
const ids = items.map(item => item.getAttribute('data-id'));
// Swap elements in ids
const temp = ids[idx];
ids[idx] = ids[idx - 1];
ids[idx - 1] = temp;
sendReorder(ids);
}
function moveItemDown(idx) {
const items = Array.from(queueList.querySelectorAll('.queue-item'));
if (idx === items.length - 1) return;
const ids = items.map(item => item.getAttribute('data-id'));
// Swap elements in ids
const temp = ids[idx];
ids[idx] = ids[idx + 1];
ids[idx + 1] = temp;
sendReorder(ids);
}
async function sendReorder(queueIds) {
try {
const res = await fetch('/api/reorder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ queue_ids: queueIds })
});
if (!res.ok) throw new Error('Reorder failed');
showToast('Zmieniono kolejność utworów!', 'success');
} catch (err) {
showToast('Nie udało się zapisać kolejności', 'error');
}
}
// Add Track to Queue
async function addTrack(e) {
e.preventDefault();
const query = trackQueryInput.value.trim();
if (!query) return;
// Check if bot is connected
if (!currentStatus || !currentStatus.active_voice_channel) {
showToast('Bot musi być połączony z kanałem głosowym!', 'error');
return;
}
// Show spinner
btnAddText.style.display = 'none';
btnAddSpinner.style.display = 'inline-block';
btnAddTrack.disabled = true;
trackQueryInput.disabled = true;
try {
const res = await fetch('/api/play', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: query })
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.detail || 'Failed to add');
}
showToast(`Dodano utwór do kolejki!`, 'success');
trackQueryInput.value = '';
} catch (err) {
showToast(`Błąd dodawania: ${err.message}`, 'error');
} finally {
btnAddText.style.display = 'inline-flex';
btnAddSpinner.style.display = 'none';
btnAddTrack.disabled = false;
trackQueryInput.disabled = false;
}
}
// Remove Track
async function removeTrack(id) {
try {
const res = await fetch('/api/remove', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: id })
});
if (!res.ok) throw new Error('Remove failed');
showToast('Usunięto utwór z kolejki', 'success');
} catch (err) {
showToast('Nie udało się usunąć utworu', 'error');
}
}
// 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) {
// Disconnect
try {
const res = await fetch('/api/disconnect', { method: 'POST' });
if (!res.ok) throw new Error('Disconnect failed');
showToast('Rozłączono z kanału głosowego', 'info');
} catch (err) {
showToast('Błąd rozłączania', 'error');
}
} else {
// Connect
const channelId = voiceSelect.value;
if (!channelId) {
showToast('Wybierz kanał głosowy z listy!', 'error');
return;
}
try {
const res = await fetch('/api/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ channel_id: channelId })
});
if (!res.ok) throw new Error('Connect failed');
showToast('Połączono z kanałem głosowym!', 'success');
} catch (err) {
showToast('Nie udało się połączyć', 'error');
}
}
}
// Toggle Play / Pause
async function togglePlayPause() {
if (!currentStatus) return;
const endpoint = currentStatus.playing ? '/api/pause' : '/api/resume';
try {
const res = await fetch(endpoint, { method: 'POST' });
if (!res.ok) throw new Error('Action failed');
showToast(currentStatus.playing ? 'Wstrzymano odtwarzanie' : 'Wznowiono odtwarzanie', 'info');
} catch (err) {
showToast('Błąd sterowania odtwarzaniem', 'error');
}
}
// Skip Track
async function skipTrack() {
try {
const res = await fetch('/api/skip', { method: 'POST' });
if (!res.ok) throw new Error('Skip failed');
showToast('Pominięto utwór', 'info');
} catch (err) {
showToast('Błąd pomijania utworu', 'error');
}
}
// Stop Playback
async function stopPlayback() {
try {
const res = await fetch('/api/stop', { method: 'POST' });
if (!res.ok) throw new Error('Stop failed');
showToast('Zatrzymano odtwarzanie i wyczyszczono kolejkę', 'info');
} catch (err) {
showToast('Błąd zatrzymywania', 'error');
}
}
// Toggle Loop State (Cycle: Off -> Playlist -> Single -> Off)
async function toggleLoop() {
if (!currentStatus) return;
try {
const res = await fetch('/api/loop_cycle', { method: 'POST' });
if (!res.ok) throw new Error('Loop cycle failed');
const data = await res.json();
if (data.mode === 'playlist') showToast('Zapętlanie kolejki włączone', 'info');
else if (data.mode === 'single') showToast('Zapętlanie utworu włączone', 'info');
else showToast('Zapętlanie wyłączone', 'info');
} catch (err) {
showToast('Błąd ustawiania pętli', 'error');
}
}
// Set Volume
async function setVolume(vol) {
try {
const res = await fetch('/api/volume', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ volume: vol })
});
if (!res.ok) throw new Error('Volume failed');
} catch (err) {
showToast('Nie udało się zmienić głośności', 'error');
}
}
// Settings Modal Functions
function openSettingsModal() {
if (settingsModal) settingsModal.style.display = 'flex';
}
function closeSettingsModal() {
if (settingsModal) settingsModal.style.display = 'none';
}
function renderSettingsGuilds(guilds) {
if (!settingsGuildsList) return;
// Don't re-render if user is currently interacting with a select inside settings
if (settingsModal && settingsModal.style.display !== 'none' && document.activeElement && document.activeElement.classList.contains('guild-log-select')) {
return;
}
if (!guilds || guilds.length === 0) {
settingsGuildsList.innerHTML = '<div class="empty-queue-message"><i data-lucide="server"></i><p>Brak serwerów Discord w pamięci bota.</p></div>';
lucide.createIcons();
return;
}
let html = '';
guilds.forEach(guild => {
let optionsHtml = '';
if (guild.text_channels && guild.text_channels.length > 0) {
guild.text_channels.forEach(tc => {
const isSelected = guild.active_notification_channel_id === tc.channel_id ? 'selected' : '';
optionsHtml += `<option value="${tc.channel_id}" ${isSelected}>#${tc.channel_name}</option>`;
});
} else {
optionsHtml = '<option value="" disabled>Brak kanałów tekstowych</option>';
}
html += `
<div class="guild-setting-row glass-card-subtle">
<div class="guild-info">
<div class="guild-icon"><i data-lucide="shield"></i></div>
<div>
<div class="guild-name">${guild.guild_name}</div>
<div class="guild-id">ID: ${guild.guild_id}</div>
</div>
</div>
<div class="guild-control">
<select class="premium-select guild-log-select" data-guild-id="${guild.guild_id}" onchange="saveGuildLogChannel('${guild.guild_id}', this.value)">
<option value="">-- Brak / Domyślny --</option>
${optionsHtml}
</select>
</div>
</div>
`;
});
settingsGuildsList.innerHTML = html;
lucide.createIcons();
}
async function saveGuildLogChannel(guildId, channelId) {
try {
const res = await fetch('/api/settings/text_channel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ guild_id: guildId, channel_id: channelId || "" })
});
if (!res.ok) throw new Error('Settings change failed');
showToast('Zapisano kanał powiadomień dla serwera!', 'success');
} catch (err) {
showToast('Nie udało się zapisać kanału powiadomień', 'error');
}
}
async function changeTrackPlays(trackId, delta) {
if (!currentStatus || !currentStatus.queue) return;
const track = currentStatus.queue.find(t => t.id === trackId);
if (!track) return;
const currentPlays = track.plays_left !== undefined ? track.plays_left : (currentStatus.default_plays || 5);
const newPlays = Math.max(1, Math.min(999, currentPlays + delta));
if (newPlays === currentPlays) return;
try {
const res = await fetch('/api/queue/plays', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: trackId, plays: newPlays })
});
if (!res.ok) throw new Error('Failed to update plays');
} catch (err) {
showToast('Błąd aktualizacji odtworzeń', 'error');
}
}
async function changeTrackPlaysDirect(trackId, val) {
const newPlays = parseInt(val, 10);
if (isNaN(newPlays) || newPlays < 1 || newPlays > 999) {
showToast('Wartość musi być z przedziału 1-999', 'error');
if (currentStatus) renderQueue(currentStatus.queue, currentStatus.queue_index);
return;
}
try {
const res = await fetch('/api/queue/plays', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: trackId, plays: newPlays })
});
if (!res.ok) throw new Error('Failed to update plays');
} catch (err) {
showToast('Błąd aktualizacji odtworzeń', 'error');
if (currentStatus) renderQueue(currentStatus.queue, currentStatus.queue_index);
}
}
// Fetch Uploaded Files
async function fetchUploads() {
try {
const res = await fetch('/api/uploads');
if (!res.ok) return;
const data = await res.json();
renderUploads(data.files || []);
} catch (err) {
console.error('Błąd pobierania plików:', err);
}
}
let allUploadedFiles = [];
// Open Uploads Modal
function openUploadsModal() {
if (uploadsModal) {
uploadsModal.style.display = 'flex';
renderModalUploads(allUploadedFiles);
}
}
// Close Uploads Modal
function closeUploadsModal() {
if (uploadsModal) uploadsModal.style.display = 'none';
}
// Render Modal Uploads (All files)
function renderModalUploads(files) {
if (!modalUploadsList) return;
const items = modalUploadsList.querySelectorAll('.queue-item');
items.forEach(el => el.remove());
if (!files || files.length === 0) {
if (emptyModalUploadsMsg) emptyModalUploadsMsg.style.display = 'flex';
return;
}
if (emptyModalUploadsMsg) emptyModalUploadsMsg.style.display = 'none';
files.forEach(file => {
const li = document.createElement('li');
li.className = 'queue-item';
const safeFilename = file.filename.replace(/'/g, "\\'");
li.innerHTML = `
<div class="queue-index"><i data-lucide="file-audio"></i></div>
<div class="queue-track-info" style="flex: 1; min-width: 0;">
<div class="queue-track-title" title="${file.filename}" style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${file.filename}</div>
<div class="queue-track-artist">${file.size_mb} MB</div>
</div>
<div class="queue-item-actions" style="display: flex; gap: 6px; align-items: center;">
<button class="btn btn-primary" style="padding: 4px 10px; font-size: 0.8rem; display: flex; align-items: center; gap: 4px;" onclick="playUploadedFile('${safeFilename}')" title="Dodaj do kolejki">
<i data-lucide="plus"></i> Dodaj
</button>
<button class="btn-action-icon delete" onclick="deleteUploadedFile('${safeFilename}')" title="Usuń z serwera">
<i data-lucide="trash-2"></i>
</button>
</div>
`;
modalUploadsList.appendChild(li);
});
lucide.createIcons();
}
// Render Uploaded Files (Top 10 most recent on dashboard panel)
function renderUploads(files) {
allUploadedFiles = files || [];
if (btnAllUploadsCount) btnAllUploadsCount.textContent = allUploadedFiles.length;
if (!uploadsList) return;
const items = uploadsList.querySelectorAll('.queue-item');
items.forEach(el => el.remove());
if (!files || files.length === 0) {
emptyUploadsMsg.style.display = 'flex';
uploadsCountBadge.textContent = '0 plików';
if (modalUploadsList) renderModalUploads([]);
return;
}
emptyUploadsMsg.style.display = 'none';
uploadsCountBadge.textContent = `${files.length} ${files.length === 1 ? 'plik' : files.length < 5 ? 'pliki' : 'plików'}`;
// Take only top 10 most recently used/uploaded
const top10 = files.slice(0, 10);
top10.forEach(file => {
const li = document.createElement('li');
li.className = 'queue-item';
const safeFilename = file.filename.replace(/'/g, "\\'");
li.innerHTML = `
<div class="queue-index"><i data-lucide="file-audio"></i></div>
<div class="queue-track-info" style="flex: 1; min-width: 0;">
<div class="queue-track-title" title="${file.filename}" style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${file.filename}</div>
<div class="queue-track-artist">${file.size_mb} MB</div>
</div>
<div class="queue-item-actions" style="display: flex; gap: 6px; align-items: center;">
<button class="btn btn-primary" style="padding: 4px 10px; font-size: 0.8rem; display: flex; align-items: center; gap: 4px;" onclick="playUploadedFile('${safeFilename}')" title="Dodaj do kolejki">
<i data-lucide="plus"></i> Dodaj
</button>
<button class="btn-action-icon delete" onclick="deleteUploadedFile('${safeFilename}')" title="Usuń z serwera">
<i data-lucide="trash-2"></i>
</button>
</div>
`;
uploadsList.appendChild(li);
});
if (uploadsModal && uploadsModal.style.display !== 'none') {
renderModalUploads(allUploadedFiles);
}
lucide.createIcons();
}
// Handle File Upload
async function handleFileUpload(e) {
const files = e.target.files;
if (!files || files.length === 0) return;
uploadProgressContainer.style.display = 'block';
for (let i = 0; i < files.length; i++) {
const file = files[i];
const formData = new FormData();
formData.append('file', file);
uploadStatusText.textContent = `Przesyłanie: ${file.name}...`;
uploadProgressFill.style.width = '50%';
try {
const res = await fetch('/api/upload', {
method: 'POST',
body: formData
});
if (!res.ok) {
const errData = await res.json();
throw new Error(errData.detail || 'Błąd przesyłania');
}
showToast(`Przesłano plik: ${file.name}`, 'success');
} catch (err) {
showToast(`Błąd przesyłania ${file.name}: ${err.message}`, 'error');
}
}
uploadProgressFill.style.width = '100%';
setTimeout(() => {
uploadProgressContainer.style.display = 'none';
uploadProgressFill.style.width = '0%';
fileInput.value = '';
fetchUploads();
}, 500);
}
// Play Uploaded File
async function playUploadedFile(filename) {
if (!currentStatus || !currentStatus.active_voice_channel) {
showToast('Bot musi być połączony z kanałem głosowym!', 'error');
return;
}
try {
const res = await fetch('/api/play_file', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: filename })
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Błąd odtwarzania pliku');
showToast(`Dodano plik do kolejki!`, 'success');
} catch (err) {
showToast(`Błąd odtwarzania: ${err.message}`, 'error');
}
}
// Delete Uploaded File
async function deleteUploadedFile(filename) {
try {
const res = await fetch(`/api/uploads/${encodeURIComponent(filename)}`, {
method: 'DELETE'
});
if (!res.ok) throw new Error('Błąd usuwania');
showToast('Usunięto plik z serwera', 'info');
fetchUploads();
} catch (err) {
showToast('Nie udało się usunąć pliku', 'error');
}
}
// Render History
function renderHistory(history) {
if (!historyList) return;
const items = historyList.querySelectorAll('.queue-item');
items.forEach(el => el.remove());
if (!history || history.length === 0) {
if (emptyHistoryMsg) emptyHistoryMsg.style.display = 'flex';
if (historyCountBadge) historyCountBadge.textContent = '0 utworów';
return;
}
if (emptyHistoryMsg) emptyHistoryMsg.style.display = 'none';
if (historyCountBadge) historyCountBadge.textContent = `${history.length} ${history.length === 1 ? 'utwór' : history.length < 5 ? 'utwory' : 'utworów'}`;
history.forEach((item, index) => {
const li = document.createElement('li');
li.className = 'queue-item';
let durationStr = '--:--';
if (item.duration) {
const mins = Math.floor(item.duration / 60);
const secs = Math.floor(item.duration % 60);
durationStr = `${mins}:${secs < 10 ? '0' : ''}${secs}`;
}
const safeTitle = (item.title || 'Nieznany tytuł').replace(/'/g, "\\'");
const safeQuery = (item.is_local ? (item.filepath || item.title) : item.url).replace(/'/g, "\\'");
li.innerHTML = `
<div class="queue-index"><i data-lucide="rotate-ccw"></i></div>
<div class="queue-track-info" style="flex: 1; min-width: 0;">
<div class="queue-track-title" title="${item.title}" style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${item.title}</div>
<div class="queue-track-artist">${durationStr} ${item.is_local ? '• plik lokalny' : ''}</div>
</div>
<div class="queue-item-actions">
<button class="btn btn-secondary" style="padding: 4px 10px; font-size: 0.8rem; display: flex; align-items: center; gap: 4px;" onclick="replayFromHistory('${safeQuery}', ${item.is_local})" title="Dodaj ponownie do kolejki">
<i data-lucide="plus"></i> Dodaj
</button>
</div>
`;
historyList.appendChild(li);
});
lucide.createIcons();
}
// Replay from History
async function replayFromHistory(queryOrFile, isLocal) {
if (!currentStatus || !currentStatus.active_voice_channel) {
showToast('Bot musi być połączony z kanałem głosowym!', 'error');
return;
}
if (isLocal) {
const filename = queryOrFile.split('/').pop().split('\\').pop();
playUploadedFile(filename);
} else {
try {
const res = await fetch('/api/play', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: queryOrFile })
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Błąd odtwarzania');
showToast('Dodano z historii do kolejki!', 'success');
} catch (err) {
showToast(`Błąd: ${err.message}`, 'error');
}
}
}