360 lines
11 KiB
JavaScript
360 lines
11 KiB
JavaScript
(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");
|
|
|
|
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.`);
|
|
|
|
if (confirmed) {
|
|
// Confirm playlist download
|
|
const confirmRes = await fetch("/api/fetch/confirm", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ items: data.items })
|
|
});
|
|
|
|
if (confirmRes.ok) {
|
|
const confirmData = await confirmRes.json();
|
|
M.showToast(confirmData.message);
|
|
// Tasks will be created by WebSocket progress messages
|
|
} 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<id, taskHandle>
|
|
|
|
function updateTasksEmpty() {
|
|
const hasTasks = tasksList.children.length > 0;
|
|
tasksEmpty.classList.toggle("hidden", hasTasks);
|
|
}
|
|
|
|
// 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);
|
|
} else if (data.status === "error") {
|
|
task.setError(data.error || "Failed");
|
|
fetchTasks.delete(data.id);
|
|
}
|
|
};
|
|
|
|
function createTask(filename, fetchId) {
|
|
const task = document.createElement("div");
|
|
task.className = "task-item";
|
|
task.innerHTML = `
|
|
<span class="task-spinner"></span>
|
|
<span class="task-icon"></span>
|
|
<span class="task-name">${filename}</span>
|
|
<span class="task-progress">0%</span>
|
|
<div class="task-bar" style="width: 0%"></div>
|
|
`;
|
|
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`);
|
|
}
|
|
}
|
|
});
|
|
})();
|