622 lines
21 KiB
JavaScript
622 lines
21 KiB
JavaScript
// Global variables
|
|
let isDragging = false;
|
|
let currentStatus = null;
|
|
let currentVolume = 50;
|
|
let isUpdatingVolume = false;
|
|
|
|
// DOM Elements
|
|
const statusDot = document.getElementById('status-dot');
|
|
const statusText = document.getElementById('status-text');
|
|
const voiceSelect = document.getElementById('voice-channel-select');
|
|
const textChannelSelect = document.getElementById('text-channel-select');
|
|
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');
|
|
|
|
// Initialize
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
// Initial fetch
|
|
fetchStatus();
|
|
|
|
// Start Poller (every 1 second)
|
|
setInterval(() => {
|
|
if (!isDragging) {
|
|
fetchStatus();
|
|
}
|
|
}, 1000);
|
|
|
|
// 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);
|
|
|
|
// Text Channel Select
|
|
textChannelSelect.addEventListener('change', changeTextChannel);
|
|
}
|
|
|
|
// 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';
|
|
}
|
|
}
|
|
|
|
// Update UI States
|
|
function updateUI(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);
|
|
}
|
|
}
|
|
|
|
// Text selector population (only if select doesn't have focus/interaction)
|
|
if (document.activeElement !== textChannelSelect) {
|
|
const currentSelectedText = textChannelSelect.value;
|
|
textChannelSelect.innerHTML = '<option value="" disabled>Wybierz kanał...</option>';
|
|
|
|
if (status.text_channels && status.text_channels.length > 0) {
|
|
status.text_channels.forEach(channel => {
|
|
const opt = document.createElement('option');
|
|
opt.value = channel.channel_id;
|
|
opt.textContent = `${channel.guild_name} - #${channel.channel_name}`;
|
|
textChannelSelect.appendChild(opt);
|
|
});
|
|
|
|
if (status.active_text_channel_id) {
|
|
textChannelSelect.value = status.active_text_channel_id;
|
|
} else if (currentSelectedText && status.text_channels.some(c => c.channel_id === currentSelectedText)) {
|
|
textChannelSelect.value = currentSelectedText;
|
|
}
|
|
} else {
|
|
const opt = document.createElement('option');
|
|
opt.value = "";
|
|
opt.textContent = "Brak dostępnych kanałów";
|
|
textChannelSelect.appendChild(opt);
|
|
}
|
|
}
|
|
|
|
// 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';
|
|
|
|
// 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');
|
|
}
|
|
|
|
// 3. Play/Pause Button State
|
|
if (status.playing) {
|
|
playPauseIcon.setAttribute('data-lucide', 'pause');
|
|
btnPlayPause.title = "Wstrzymaj";
|
|
} else {
|
|
playPauseIcon.setAttribute('data-lucide', 'play');
|
|
btnPlayPause.title = "Odtwarzaj";
|
|
}
|
|
|
|
// 4. Volume State
|
|
if (!isUpdatingVolume) {
|
|
volumeSlider.value = status.volume;
|
|
volumeValue.textContent = `${status.volume}%`;
|
|
updateVolumeIcon(status.volume);
|
|
}
|
|
|
|
// 5. Loop State
|
|
if (status.loop_single) {
|
|
loopBadge.style.display = 'flex';
|
|
btnLoop.classList.add('active');
|
|
} else {
|
|
loopBadge.style.display = 'none';
|
|
btnLoop.classList.remove('active');
|
|
}
|
|
|
|
// 6. Queue render (if not currently dragging)
|
|
if (!isDragging) {
|
|
renderQueue(status.queue);
|
|
}
|
|
|
|
// Re-trigger lucide icons update
|
|
lucide.createIcons();
|
|
}
|
|
|
|
// Render Queue Items list
|
|
function renderQueue(queue) {
|
|
// 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');
|
|
li.className = 'queue-item';
|
|
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>
|
|
<div class="queue-index">${idx + 1}</div>
|
|
<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="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');
|
|
fetchStatus();
|
|
} catch (err) {
|
|
showToast('Nie udało się zapisać kolejności', 'error');
|
|
fetchStatus();
|
|
}
|
|
}
|
|
|
|
// 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 = '';
|
|
fetchStatus();
|
|
} 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');
|
|
fetchStatus();
|
|
} catch (err) {
|
|
showToast('Nie udało się usunąć 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');
|
|
fetchStatus();
|
|
} 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');
|
|
fetchStatus();
|
|
} 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');
|
|
fetchStatus();
|
|
} 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');
|
|
fetchStatus();
|
|
} 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');
|
|
fetchStatus();
|
|
} catch (err) {
|
|
showToast('Błąd zatrzymywania', 'error');
|
|
}
|
|
}
|
|
|
|
// Toggle Loop State
|
|
async function toggleLoop() {
|
|
if (!currentStatus) return;
|
|
const nextState = !currentStatus.loop_single;
|
|
try {
|
|
const res = await fetch('/api/loop', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ loop: nextState })
|
|
});
|
|
if (!res.ok) throw new Error('Loop failed');
|
|
showToast(nextState ? 'Zapętlanie utworu włączone' : 'Zapętlanie utworu wyłączone', 'info');
|
|
fetchStatus();
|
|
} 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');
|
|
fetchStatus();
|
|
} catch (err) {
|
|
showToast('Nie udało się zmienić głośności', 'error');
|
|
}
|
|
}
|
|
|
|
// Change Text Notification Channel
|
|
async function changeTextChannel() {
|
|
const channelId = textChannelSelect.value;
|
|
if (!channelId) return;
|
|
|
|
try {
|
|
const res = await fetch('/api/settings/text_channel', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ channel_id: channelId })
|
|
});
|
|
if (!res.ok) throw new Error('Settings change failed');
|
|
showToast('Zapisano kanał powiadomień na Discordzie!', 'success');
|
|
fetchStatus();
|
|
} catch (err) {
|
|
showToast('Nie udało się zapisać kanału powiadomień', 'error');
|
|
}
|
|
}
|