diff --git a/public/channelSync.js b/public/channelSync.js index a714ced..c8b0b76 100644 --- a/public/channelSync.js +++ b/public/channelSync.js @@ -193,6 +193,7 @@ const oldWs = M.ws; M.ws = null; oldWs.onclose = null; + oldWs.onerror = null; oldWs.close(); } M.currentChannelId = id; @@ -200,6 +201,9 @@ const proto = location.protocol === "https:" ? "wss:" : "ws:"; M.ws = new WebSocket(proto + "//" + location.host + "/api/channels/" + id + "/ws"); + // Track if we've ever connected successfully + let wasConnected = false; + M.ws.onmessage = (e) => { const data = JSON.parse(e.data); // Handle channel list updates @@ -225,6 +229,7 @@ const oldWs = M.ws; M.ws = null; oldWs.onclose = null; + oldWs.onerror = null; oldWs.close(); } M.updateUI(); @@ -279,18 +284,25 @@ M.handleUpdate(data); }; + M.ws.onerror = () => { + console.log("[WS] Connection error"); + }; + M.ws.onclose = () => { M.synced = false; M.ws = null; M.$("#sync-indicator").classList.add("disconnected"); M.updateUI(); // Auto-reconnect if user wants to be synced + // Use faster retry (2s) if never connected, slower (3s) if disconnected after connecting if (M.wantSync) { - setTimeout(() => M.connectChannel(id), 3000); + const delay = wasConnected ? 3000 : 2000; + setTimeout(() => M.connectChannel(id), delay); } }; M.ws.onopen = () => { + wasConnected = true; M.synced = true; M.$("#sync-indicator").classList.remove("disconnected"); M.updateUI(); diff --git a/public/index.html b/public/index.html index 8c40c89..dfd5a70 100644 --- a/public/index.html +++ b/public/index.html @@ -56,20 +56,33 @@
- -
-

Library

- - +
+ +
-
- - -
diff --git a/public/init.js b/public/init.js index 0170ae8..4631d8d 100644 --- a/public/init.js +++ b/public/init.js @@ -23,6 +23,27 @@ console.log(`TrackStorage: ${M.cachedTracks.size} tracks cached`); } + // Setup panel tab switching + function initPanelTabs() { + const tabs = document.querySelectorAll(".panel-tab"); + tabs.forEach(tab => { + tab.onclick = () => { + const tabId = tab.dataset.tab; + const panel = tab.closest("#library-panel, #queue-panel"); + if (!panel) return; + + // Update active tab + panel.querySelectorAll(".panel-tab").forEach(t => t.classList.remove("active")); + tab.classList.add("active"); + + // Update active view + panel.querySelectorAll(".panel-view").forEach(v => v.classList.remove("active")); + const view = panel.querySelector(`#${tabId}-view`); + if (view) view.classList.add("active"); + }; + }); + } + // Setup history panel handlers document.addEventListener("DOMContentLoaded", () => { const btnHistory = M.$("#btn-history"); @@ -34,6 +55,8 @@ if (btnClose) { btnClose.onclick = () => M.toggleToastHistory(); } + + initPanelTabs(); }); // Initialize the application diff --git a/public/styles.css b/public/styles.css index 4dc1675..2220169 100644 --- a/public/styles.css +++ b/public/styles.css @@ -37,13 +37,49 @@ h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppe #channels-list .listener::before { content: ""; position: absolute; left: -0.3rem; top: 50%; width: 0.2rem; height: 1px; background: #333; } #channels-list .listener-mult { color: #666; font-size: 0.55rem; } #library-panel, #queue-panel { flex: 0 0 700px; min-width: 0; overflow: hidden; background: #1a1a1a; border-radius: 6px; padding: 0.5rem; display: flex; flex-direction: column; min-height: 250px; max-height: 60vh; position: relative; } -.scan-progress { font-size: 0.7rem; color: #ea4; padding: 0.2rem 0.4rem; background: #2a2a1a; border-radius: 3px; margin-bottom: 0.3rem; display: flex; align-items: center; gap: 0.4rem; } +.panel-tabs { display: flex; gap: 0; margin-bottom: 0; flex-shrink: 0; } +.panel-tab { background: #252525; border: none; color: #666; font-family: inherit; font-size: 0.8rem; font-weight: bold; padding: 0.3rem 0.6rem; cursor: pointer; border-radius: 4px 4px 0 0; text-transform: uppercase; letter-spacing: 0.05em; margin-right: 2px; } +.panel-tab:hover { color: #aaa; background: #2a2a2a; } +.panel-tab.active { color: #eee; background: #222; } +.panel-views { flex: 1; display: flex; flex-direction: column; min-height: 0; overflow: hidden; position: relative; background: #222; border-radius: 0 4px 4px 4px; } +.panel-view { display: none; flex-direction: column; flex: 1; min-height: 0; overflow: hidden; position: relative; padding: 0.4rem; } +.panel-view.active { display: flex; } +.tasks-empty { color: #666; font-size: 0.85rem; padding: 1rem; text-align: center; } +.tasks-empty.hidden { display: none; } +#tasks-list { display: flex; flex-direction: column; gap: 0.3rem; overflow-y: auto; flex: 1; } +.task-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0.6rem; background: #2a2a1a; border-radius: 4px; font-size: 0.8rem; color: #ea4; position: relative; overflow: hidden; } +.task-item.complete { background: #1a2a1a; color: #4e8; } +.task-item.error { background: #2a1a1a; color: #e44; } +.task-item .task-spinner { display: inline-block; width: 12px; height: 12px; border: 2px solid #ea4; border-top-color: transparent; border-radius: 50%; animation: spin 0.8s linear infinite; flex-shrink: 0; } +.task-item.complete .task-spinner { display: none; } +.task-item.error .task-spinner { display: none; } +.task-item .task-icon { flex-shrink: 0; } +.task-item.complete .task-icon::before { content: "✓"; } +.task-item.error .task-icon::before { content: "✗"; } +.task-item .task-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.task-item .task-progress { font-size: 0.7rem; color: #888; flex-shrink: 0; } +.task-item .task-bar { position: absolute; left: 0; bottom: 0; height: 2px; background: #ea4; transition: width 0.2s; } +.task-item.complete .task-bar { background: #4e8; } +.scan-progress { font-size: 0.7rem; color: #ea4; padding: 0.2rem 0.4rem; background: #2a2a1a; border-radius: 3px; margin-bottom: 0.3rem; display: flex; align-items: center; gap: 0.4rem; flex-shrink: 0; } .scan-progress.hidden { display: none; } .scan-progress.complete { color: #4e8; background: #1a2a1a; } .scan-progress .spinner { display: inline-block; width: 10px; height: 10px; border: 2px solid #ea4; border-top-color: transparent; border-radius: 50%; animation: spin 0.8s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } .upload-btn { width: 100%; padding: 0.5rem; background: #2a3a2a; border: 1px dashed #4e8; border-radius: 4px; color: #4e8; font-size: 0.85rem; cursor: pointer; margin-top: 0.3rem; flex-shrink: 0; } .upload-btn:hover { background: #3a4a3a; } +.add-btn { width: 100%; padding: 0.5rem; background: #252525; border: 1px solid #444; border-radius: 4px; color: #888; font-size: 0.85rem; cursor: pointer; margin-top: 0.3rem; flex-shrink: 0; transition: all 0.2s; } +.add-btn:hover { background: #2a2a2a; border-color: #666; color: #aaa; } +.add-btn.hidden { display: none; } +.add-panel { position: absolute; bottom: 0; left: 0; right: 0; background: #1a1a1a; border-top: 1px solid #333; border-radius: 0 0 6px 6px; display: flex; flex-direction: column; height: 50%; overflow: hidden; animation: panelSlideUp 0.2s ease-out; } +.add-panel.hidden { display: none; } +.add-panel.closing { animation: panelSlideDown 0.2s ease-in forwards; } +@keyframes panelSlideUp { from { transform: translateY(100%); opacity: 0; } to { transform: translateY(0); opacity: 1; } } +@keyframes panelSlideDown { from { transform: translateY(0); opacity: 1; } to { transform: translateY(100%); opacity: 0; } } +.add-panel-close { width: calc(100% - 1rem); margin: 0.5rem; padding: 0.5rem; background: #252525; border: 1px solid #444; border-radius: 4px; color: #888; font-size: 0.85rem; cursor: pointer; flex-shrink: 0; } +.add-panel-close:hover { background: #2a2a2a; border-color: #666; color: #aaa; } +.add-panel-content { flex: 1; overflow-y: auto; padding: 0 0.5rem 0.5rem; display: flex; flex-direction: column; gap: 0.3rem; } +.add-option { width: 100%; padding: 0.6rem 0.8rem; background: #222; border: 1px solid #333; border-radius: 4px; color: #aaa; font-size: 0.85rem; cursor: pointer; text-align: left; transition: all 0.15s; } +.add-option:hover { background: #2a3a2a; border-color: #4e8; color: #4e8; } .upload-progress { display: flex; align-items: center; gap: 0.4rem; padding: 0.3rem 0.5rem; background: #2a2a1a; border-radius: 4px; margin-top: 0.3rem; flex-shrink: 0; position: relative; overflow: hidden; } .upload-progress.hidden { display: none; } .upload-progress-bar { position: absolute; left: 0; top: 0; bottom: 0; background: #4a6a4a; transition: width 0.2s; } diff --git a/public/upload.js b/public/upload.js index d500d19..be0e971 100644 --- a/public/upload.js +++ b/public/upload.js @@ -2,28 +2,57 @@ const M = window.MusicRoom; document.addEventListener("DOMContentLoaded", () => { - const uploadBtn = M.$("#btn-upload"); + 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 progressEl = M.$("#upload-progress"); - const progressBar = progressEl?.querySelector(".upload-progress-bar"); - const progressText = progressEl?.querySelector(".upload-progress-text"); + const tasksList = M.$("#tasks-list"); + const tasksEmpty = M.$("#tasks-empty"); - if (!uploadBtn || !fileInput || !dropzone) return; + if (!addBtn || !fileInput || !dropzone) return; - // Click upload button opens file dialog - uploadBtn.onclick = () => { + 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 upload"); + M.showToast("Sign in to add tracks"); return; } + openPanel(); + }; + + // Close add panel + addCloseBtn.onclick = () => { + closePanel(); + }; + + // Upload files option + uploadFilesBtn.onclick = () => { fileInput.click(); }; // File input change fileInput.onchange = () => { if (fileInput.files.length > 0) { + closePanel(); uploadFiles(fileInput.files); fileInput.value = ""; } @@ -71,19 +100,91 @@ } }; - function showProgress(current, total, filename) { - if (!progressEl) return; - const pct = Math.round((current / total) * 100); - progressBar.style.width = pct + "%"; - progressText.textContent = `Uploading ${current}/${total}: ${filename}`; - progressEl.classList.remove("hidden"); - uploadBtn.classList.add("hidden"); + // Task management + function updateTasksEmpty() { + const hasTasks = tasksList.children.length > 0; + tasksEmpty.classList.toggle("hidden", hasTasks); } - function hideProgress() { - if (!progressEl) return; - progressEl.classList.add("hidden"); - uploadBtn.classList.remove("hidden"); + function createTask(filename) { + 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(); + + return { + 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); + } + }; + } + + 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) { @@ -100,44 +201,30 @@ let uploaded = 0; let failed = 0; - let duplicates = 0; - const total = audioFiles.length; - for (let i = 0; i < audioFiles.length; i++) { - const file = audioFiles[i]; - showProgress(i + 1, total, file.name); - - try { - const formData = new FormData(); - formData.append("file", file); - - const res = await fetch("/api/upload", { - method: "POST", - body: formData + // 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); }); - - if (res.ok) { - uploaded++; - } else if (res.status === 409) { - // File already exists - M.showToast(`Already uploaded: ${file.name}`, "warning"); - duplicates++; - } else { - const err = await res.json().catch(() => ({})); - console.error(`Upload failed for ${file.name}:`, err.error || res.status); - failed++; - } - } catch (e) { - console.error(`Upload error for ${file.name}:`, e); - failed++; + active.push(promise); + } + if (active.length > 0) { + await Promise.race(active); } } - hideProgress(); - if (uploaded > 0) { M.showToast(`Uploaded ${uploaded} track${uploaded > 1 ? 's' : ''}${failed > 0 ? `, ${failed} failed` : ''}`); - } else { + } else if (failed > 0) { M.showToast(`Upload failed`); } } diff --git a/server.ts b/server.ts index 2e195d2..5158937 100644 --- a/server.ts +++ b/server.ts @@ -341,7 +341,7 @@ function userHasPermission(user: ReturnType, resourceType: strin if (user.is_guest && permission === "control") return false; // Check default permissions from config - if (resourceType === "channel" && config.defaultPermissions.channel?.includes(permission)) { + if (resourceType === "channel" && config.defaultPermissions?.includes(permission)) { return true; } @@ -651,8 +651,8 @@ serve({ const permissions = getUserPermissions(user.id); // Add default permissions for all users (except control for guests) const effectivePermissions = [...permissions]; - if (config.defaultPermissions.channel) { - for (const perm of config.defaultPermissions.channel) { + if (config.defaultPermissions) { + for (const perm of config.defaultPermissions) { // Guests can never have control permission if (user.is_guest && perm === "control") continue; effectivePermissions.push({ @@ -999,7 +999,7 @@ serve({ // Check default permissions or user-specific permissions const canControl = user.is_admin - || config.defaultPermissions.channel?.includes("control") + || config.defaultPermissions?.includes("control") || hasPermission(userId, "channel", ws.data.channelId, "control"); if (!canControl) { console.log("[WS] User lacks control permission:", user.username);