(function() { const M = window.MusicRoom; document.addEventListener("DOMContentLoaded", () => { const addBtn = M.$("#btn-add"); const addPanel = M.$("#add-panel"); const addCloseBtn = M.$("#btn-add-close"); const uploadFilesBtn = M.$("#btn-upload-files"); const fileInput = M.$("#file-input"); const dropzone = M.$("#upload-dropzone"); const libraryPanel = M.$("#library-panel"); const tasksList = M.$("#tasks-list"); const tasksEmpty = M.$("#tasks-empty"); // Slow queue section elements (created dynamically) let slowQueueSection = null; let slowQueuePollInterval = null; if (!addBtn || !fileInput || !dropzone) return; function openPanel() { addPanel.classList.remove("hidden", "closing"); addBtn.classList.add("hidden"); } function closePanel() { addPanel.classList.add("closing"); addBtn.classList.remove("hidden"); addPanel.addEventListener("animationend", () => { if (addPanel.classList.contains("closing")) { addPanel.classList.add("hidden"); addPanel.classList.remove("closing"); } }, { once: true }); } // Open add panel addBtn.onclick = () => { if (!M.currentUser) { M.showToast("Sign in to add tracks"); return; } openPanel(); }; // Close add panel addCloseBtn.onclick = () => { closePanel(); }; // Upload files option uploadFilesBtn.onclick = () => { fileInput.click(); }; // Fetch from URL option const fetchUrlBtn = M.$("#btn-fetch-url"); const fetchDialog = M.$("#fetch-dialog"); const fetchCloseBtn = M.$("#btn-fetch-close"); const fetchUrlInput = M.$("#fetch-url-input"); const fetchSubmitBtn = M.$("#btn-fetch-submit"); function openFetchDialog() { closePanel(); fetchDialog.classList.remove("hidden", "closing"); fetchUrlInput.value = ""; fetchUrlInput.focus(); } function closeFetchDialog() { fetchDialog.classList.add("closing"); fetchDialog.addEventListener("animationend", () => { if (fetchDialog.classList.contains("closing")) { fetchDialog.classList.add("hidden"); fetchDialog.classList.remove("closing"); } }, { once: true }); } if (fetchUrlBtn) { fetchUrlBtn.onclick = openFetchDialog; } if (fetchCloseBtn) { fetchCloseBtn.onclick = closeFetchDialog; } if (fetchSubmitBtn) { fetchSubmitBtn.onclick = async () => { const url = fetchUrlInput.value.trim(); if (!url) { M.showToast("Please enter a URL"); return; } closeFetchDialog(); M.showToast("Checking URL..."); try { const res = await fetch("/api/fetch", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url }) }); if (res.ok) { const data = await res.json(); if (data.type === "playlist") { // Ask user to confirm playlist download const confirmed = confirm(`Download playlist "${data.title}" with ${data.count} items?\n\nItems will be downloaded slowly (one every ~3 minutes) to avoid overloading the server.\n\nA playlist will be created automatically.`); if (confirmed) { // Confirm playlist download with title for auto-playlist creation const confirmRes = await fetch("/api/fetch/confirm", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ items: data.items, playlistTitle: data.title }) }); if (confirmRes.ok) { const confirmData = await confirmRes.json(); M.showToast(`${confirmData.message} → "${confirmData.playlistName}"`); // Refresh playlists to show the new one if (M.playlists?.load) M.playlists.load(); // Refresh slow queue display pollSlowQueue(); } else { const err = await confirmRes.json().catch(() => ({})); M.showToast(err.error || "Failed to queue playlist", "error"); } } } else if (data.type === "single") { M.showToast(`Queued: ${data.title}`); // Task will be created by WebSocket progress messages } else { M.showToast(data.message || "Fetch started"); } } else { const err = await res.json().catch(() => ({})); M.showToast(err.error || "Fetch failed", "error"); } } catch (e) { M.showToast("Fetch failed", "error"); } }; } if (fetchUrlInput) { fetchUrlInput.onkeydown = (e) => { if (e.key === "Enter") fetchSubmitBtn.click(); if (e.key === "Escape") closeFetchDialog(); }; } // File input change fileInput.onchange = () => { if (fileInput.files.length > 0) { closePanel(); uploadFiles(fileInput.files); fileInput.value = ""; } }; // Drag and drop on library panel let dragCounter = 0; libraryPanel.ondragenter = (e) => { if (!M.currentUser) return; if (!e.dataTransfer.types.includes("Files")) return; e.preventDefault(); dragCounter++; dropzone.classList.remove("hidden"); }; libraryPanel.ondragleave = (e) => { e.preventDefault(); dragCounter--; if (dragCounter === 0) { dropzone.classList.add("hidden"); } }; libraryPanel.ondragover = (e) => { if (!M.currentUser) return; if (!e.dataTransfer.types.includes("Files")) return; e.preventDefault(); e.dataTransfer.dropEffect = "copy"; }; libraryPanel.ondrop = (e) => { e.preventDefault(); dragCounter = 0; dropzone.classList.add("hidden"); if (!M.currentUser) { M.showToast("Sign in to upload"); return; } const files = e.dataTransfer.files; if (files.length > 0) { uploadFiles(files); } }; // Task management const fetchTasks = new Map(); // Map function updateTasksEmpty() { const hasActiveTasks = tasksList.children.length > 0; const hasSlowQueue = slowQueueSection && !slowQueueSection.classList.contains("hidden"); tasksEmpty.classList.toggle("hidden", hasActiveTasks || hasSlowQueue); } // Slow queue display function createSlowQueueSection() { if (slowQueueSection) return slowQueueSection; slowQueueSection = document.createElement("div"); slowQueueSection.className = "slow-queue-section hidden"; slowQueueSection.innerHTML = `
Playlist Queue
`; // Wire up cancel all button slowQueueSection.querySelector(".slow-queue-cancel-all").onclick = async () => { try { const res = await fetch("/api/fetch", { method: "DELETE" }); if (res.ok) { const data = await res.json(); M.showToast(data.message); pollSlowQueue(); } else { M.showToast("Failed to cancel", "error"); } } catch (e) { M.showToast("Failed to cancel", "error"); } }; // Insert before tasks-empty tasksEmpty.parentNode.insertBefore(slowQueueSection, tasksEmpty); return slowQueueSection; } function formatTime(seconds) { if (seconds <= 0) return "now"; const mins = Math.floor(seconds / 60); const secs = seconds % 60; if (mins > 0) return `${mins}m ${secs}s`; return `${secs}s`; } const QUEUE_PREVIEW_COUNT = 5; let showAllQueue = false; function updateSlowQueueDisplay(slowQueue, slowQueueNextIn) { const section = createSlowQueueSection(); const queuedItems = slowQueue.filter(i => i.status === "queued"); if (queuedItems.length === 0) { section.classList.add("hidden"); showAllQueue = false; updateTasksEmpty(); return; } section.classList.remove("hidden"); // Update header with count and timer const timerEl = section.querySelector(".slow-queue-timer"); timerEl.textContent = `${queuedItems.length} queued · next in ${formatTime(slowQueueNextIn)}`; // Determine how many items to show const itemsToShow = showAllQueue ? queuedItems : queuedItems.slice(0, QUEUE_PREVIEW_COUNT); const hiddenCount = queuedItems.length - itemsToShow.length; // Group items by playlist const byPlaylist = new Map(); for (const item of itemsToShow) { const key = item.playlistId || "__none__"; if (!byPlaylist.has(key)) { byPlaylist.set(key, { name: item.playlistName, items: [] }); } byPlaylist.get(key).items.push(item); } // Update list const listEl = section.querySelector(".slow-queue-list"); let html = ""; for (const [playlistId, group] of byPlaylist) { if (group.name) { html += `
📁 ${group.name}
`; } html += group.items.map((item, i) => { const isNext = queuedItems.indexOf(item) === 0; return `
${isNext ? '⏳' : '·'} ${item.title}
`; }).join(""); } // Add show more/less button if needed if (queuedItems.length > QUEUE_PREVIEW_COUNT) { if (showAllQueue) { html += ``; } else { html += ``; } } listEl.innerHTML = html; // Add cancel handlers listEl.querySelectorAll(".slow-queue-cancel").forEach(btn => { btn.onclick = async (e) => { e.stopPropagation(); const itemEl = btn.closest(".slow-queue-item"); const itemId = itemEl.dataset.id; try { const res = await fetch(`/api/fetch/${itemId}`, { method: "DELETE" }); if (res.ok) { itemEl.remove(); pollSlowQueue(); } else { M.showToast("Cannot cancel item", "error"); } } catch (e) { M.showToast("Failed to cancel", "error"); } }; }); // Add show more/less handler const toggleBtn = listEl.querySelector(".slow-queue-show-toggle"); if (toggleBtn) { toggleBtn.onclick = () => { showAllQueue = !showAllQueue; updateSlowQueueDisplay(slowQueue, slowQueueNextIn); }; } updateTasksEmpty(); } async function pollSlowQueue() { if (!M.currentUser || M.currentUser.isGuest) return; try { const res = await fetch("/api/fetch"); if (res.ok) { const data = await res.json(); updateSlowQueueDisplay(data.slowQueue || [], data.slowQueueNextIn || 0); } } catch (e) { // Ignore poll errors } } function startSlowQueuePoll() { if (slowQueuePollInterval) return; pollSlowQueue(); slowQueuePollInterval = setInterval(pollSlowQueue, 5000); } function stopSlowQueuePoll() { if (slowQueuePollInterval) { clearInterval(slowQueuePollInterval); slowQueuePollInterval = null; } if (slowQueueSection) { slowQueueSection.classList.add("hidden"); updateTasksEmpty(); } } // Expose start/stop for auth module to call M.startSlowQueuePoll = startSlowQueuePoll; M.stopSlowQueuePoll = stopSlowQueuePoll; // Handle WebSocket fetch progress messages M.handleFetchProgress = function(data) { let task = fetchTasks.get(data.id); // Create task if we don't have one for this id if (!task && data.status !== "complete" && data.status !== "error") { task = createTask(data.title || "Downloading...", data.id); } if (!task) return; if (data.status === "downloading" || data.status === "queued") { task.setProgress(data.progress || 0); } else if (data.status === "complete") { task.setComplete(); fetchTasks.delete(data.id); // Refresh slow queue on completion pollSlowQueue(); } else if (data.status === "error") { task.setError(data.error || "Failed"); fetchTasks.delete(data.id); // Refresh slow queue on error pollSlowQueue(); } }; function createTask(filename, fetchId) { const task = document.createElement("div"); task.className = "task-item"; task.innerHTML = ` ${filename} 0%
`; tasksList.appendChild(task); updateTasksEmpty(); // Switch to tasks tab const tasksTab = document.querySelector('.panel-tab[data-tab="tasks"]'); if (tasksTab) tasksTab.click(); const taskHandle = { setProgress(percent) { task.querySelector(".task-progress").textContent = `${Math.round(percent)}%`; task.querySelector(".task-bar").style.width = `${percent}%`; }, setComplete() { task.classList.add("complete"); task.querySelector(".task-progress").textContent = "Done"; task.querySelector(".task-bar").style.width = "100%"; // Remove after delay setTimeout(() => { task.remove(); updateTasksEmpty(); }, 3000); }, setError(msg) { task.classList.add("error"); task.querySelector(".task-progress").textContent = msg || "Failed"; // Remove after delay setTimeout(() => { task.remove(); updateTasksEmpty(); }, 5000); } }; // Store fetch tasks for WebSocket updates if (fetchId) { fetchTasks.set(fetchId, taskHandle); } return taskHandle; } function uploadFile(file) { return new Promise((resolve) => { const task = createTask(file.name); const xhr = new XMLHttpRequest(); const formData = new FormData(); formData.append("file", file); xhr.upload.onprogress = (e) => { if (e.lengthComputable) { const percent = (e.loaded / e.total) * 100; task.setProgress(percent); } }; xhr.onload = () => { if (xhr.status === 200) { task.setComplete(); resolve({ success: true }); } else if (xhr.status === 409) { task.setError("Duplicate"); resolve({ success: false, duplicate: true }); } else { task.setError("Failed"); resolve({ success: false }); } }; xhr.onerror = () => { task.setError("Error"); resolve({ success: false }); }; xhr.open("POST", "/api/upload"); xhr.send(formData); }); } async function uploadFiles(files) { const validExts = [".mp3", ".ogg", ".flac", ".wav", ".m4a", ".aac", ".opus", ".wma", ".mp4"]; const audioFiles = [...files].filter(f => { const ext = f.name.toLowerCase().match(/\.[^.]+$/)?.[0]; return ext && validExts.includes(ext); }); if (audioFiles.length === 0) { M.showToast("No valid audio files"); return; } let uploaded = 0; let failed = 0; // Upload files in parallel (max 3 concurrent) const concurrency = 3; const queue = [...audioFiles]; const active = []; while (queue.length > 0 || active.length > 0) { while (active.length < concurrency && queue.length > 0) { const file = queue.shift(); const promise = uploadFile(file).then(result => { if (result.success) uploaded++; else failed++; active.splice(active.indexOf(promise), 1); }); active.push(promise); } if (active.length > 0) { await Promise.race(active); } } if (uploaded > 0) { M.showToast(`Uploaded ${uploaded} track${uploaded > 1 ? 's' : ''}${failed > 0 ? `, ${failed} failed` : ''}`); } else if (failed > 0) { M.showToast(`Upload failed`); } } }); })();