// MusicRoom - Queue module // Queue rendering and library display (function() { const M = window.MusicRoom; // Selection state for bulk operations M.selectedQueueIndices = new Set(); M.selectedLibraryIds = new Set(); // Last selected index for shift-select range let lastSelectedQueueIndex = null; let lastSelectedLibraryIndex = null; // Context menu state let activeContextMenu = null; // Close context menu when clicking elsewhere document.addEventListener("click", () => { if (activeContextMenu) { activeContextMenu.remove(); activeContextMenu = null; } }); // Show context menu function showContextMenu(e, items) { e.preventDefault(); if (activeContextMenu) activeContextMenu.remove(); const menu = document.createElement("div"); menu.className = "context-menu"; items.forEach(item => { const el = document.createElement("div"); el.className = "context-menu-item" + (item.danger ? " danger" : ""); el.textContent = item.label; el.onclick = (ev) => { ev.stopPropagation(); menu.remove(); activeContextMenu = null; item.action(); }; menu.appendChild(el); }); document.body.appendChild(menu); // Position menu, keep within viewport let x = e.clientX; let y = e.clientY; const rect = menu.getBoundingClientRect(); if (x + rect.width > window.innerWidth) x = window.innerWidth - rect.width - 5; if (y + rect.height > window.innerHeight) y = window.innerHeight - rect.height - 5; menu.style.left = x + "px"; menu.style.top = y + "px"; activeContextMenu = menu; } // Drag state for queue reordering let draggedIndices = []; let draggedLibraryIds = []; let dropTargetIndex = null; let dragSource = null; // 'queue' or 'library' // Insert library tracks into queue at position async function insertTracksAtPosition(trackIds, position) { if (!M.currentChannelId || trackIds.length === 0) return; // Build new queue with tracks inserted at position const newQueue = [...M.queue]; const newTrackIds = [...newQueue.map(t => t.id)]; newTrackIds.splice(position, 0, ...trackIds); const res = await fetch("/api/channels/" + M.currentChannelId + "/queue", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ set: newTrackIds }) }); if (res.status === 403) M.flashPermissionDenied(); else if (res.ok) { M.showToast(`Added ${trackIds.length} track${trackIds.length > 1 ? 's' : ''}`); M.clearSelections(); } } // Reorder queue on server async function reorderQueue(fromIndices, toIndex) { if (!M.currentChannelId || fromIndices.length === 0) return; // Build new queue order const newQueue = [...M.queue]; // Sort indices descending to remove from end first const sortedIndices = [...fromIndices].sort((a, b) => b - a); const movedTracks = []; // Remove items (in reverse order to preserve indices) for (const idx of sortedIndices) { movedTracks.unshift(newQueue.splice(idx, 1)[0]); } // Adjust target index for removed items before it let adjustedTarget = toIndex; for (const idx of fromIndices) { if (idx < toIndex) adjustedTarget--; } // Insert at new position newQueue.splice(adjustedTarget, 0, ...movedTracks); // Send to server const trackIds = newQueue.map(t => t.id); const res = await fetch("/api/channels/" + M.currentChannelId + "/queue", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ set: trackIds }) }); if (res.status === 403) M.flashPermissionDenied(); else if (res.ok) { M.clearSelections(); } } // Toggle selection mode (with optional shift for range select) M.toggleQueueSelection = function(index, shiftKey = false) { if (shiftKey && lastSelectedQueueIndex !== null) { // Range select: select all between last and current const start = Math.min(lastSelectedQueueIndex, index); const end = Math.max(lastSelectedQueueIndex, index); for (let i = start; i <= end; i++) { M.selectedQueueIndices.add(i); } } else { if (M.selectedQueueIndices.has(index)) { M.selectedQueueIndices.delete(index); } else { M.selectedQueueIndices.add(index); } lastSelectedQueueIndex = index; } M.renderQueue(); }; M.toggleLibrarySelection = function(index, shiftKey = false) { if (shiftKey && lastSelectedLibraryIndex !== null) { // Range select: select all between last and current const start = Math.min(lastSelectedLibraryIndex, index); const end = Math.max(lastSelectedLibraryIndex, index); for (let i = start; i <= end; i++) { M.selectedLibraryIds.add(M.library[i].id); } } else { const trackId = M.library[index].id; if (M.selectedLibraryIds.has(trackId)) { M.selectedLibraryIds.delete(trackId); } else { M.selectedLibraryIds.add(trackId); } lastSelectedLibraryIndex = index; } M.renderLibrary(); }; M.clearSelections = function() { M.selectedQueueIndices.clear(); M.selectedLibraryIds.clear(); lastSelectedQueueIndex = null; lastSelectedLibraryIndex = null; M.renderQueue(); M.renderLibrary(); }; // Update cache status for all tracks M.updateCacheStatus = async function() { const cached = await TrackStorage.list(); // Migration: remove old filename-based cache entries (keep only sha256: prefixed) const oldEntries = cached.filter(id => !id.startsWith("sha256:")); if (oldEntries.length > 0) { console.log("[Cache] Migrating: removing", oldEntries.length, "old filename-based entries"); for (const oldId of oldEntries) { await TrackStorage.remove(oldId); } // Re-fetch after cleanup const updated = await TrackStorage.list(); M.cachedTracks = new Set(updated); } else { M.cachedTracks = new Set(cached); } console.log("[Cache] Updated cache status:", M.cachedTracks.size, "tracks cached"); }; // Debug: log cache status for current track M.debugCacheStatus = function() { if (!M.currentTrackId) { console.log("[Cache Debug] No current track"); return; } const trackCache = M.getTrackCache(M.currentTrackId); const segmentsPct = Math.round((trackCache.size / M.SEGMENTS) * 100); const inCachedTracks = M.cachedTracks.has(M.currentTrackId); const hasBlobUrl = M.trackBlobs.has(M.currentTrackId); const bulkStarted = M.bulkDownloadStarted.get(M.currentTrackId); console.log("[Cache Debug]", { trackId: M.currentTrackId.slice(0, 16) + "...", segments: `${trackCache.size}/${M.SEGMENTS} (${segmentsPct}%)`, inCachedTracks, hasBlobUrl, bulkStarted, loadingSegments: [...M.loadingSegments], cachedTracksSize: M.cachedTracks.size }); }; // Debug: compare queue track IDs with cached track IDs M.debugCacheMismatch = function() { console.log("[Cache Mismatch Debug]"); console.log("=== Raw State ==="); console.log("M.cachedTracks:", M.cachedTracks); console.log("M.trackCaches:", M.trackCaches); console.log("M.trackBlobs keys:", [...M.trackBlobs.keys()]); console.log("M.bulkDownloadStarted:", M.bulkDownloadStarted); console.log("=== Queue Tracks ==="); M.queue.forEach((t, i) => { const id = t.id || t.filename; console.log(` [${i}] ${t.title?.slice(0, 30)} | id: ${id?.slice(0, 12)}... | cached: ${M.cachedTracks.has(id)}`); }); console.log("=== Cached Track IDs ==="); [...M.cachedTracks].forEach(id => { console.log(` ${id.slice(0, 20)}...`); }); }; // Debug: detailed info for a specific track M.debugTrack = function(index) { const track = M.queue[index]; if (!track) { console.log("[Debug] No track at index", index); return; } const id = track.id || track.filename; console.log("[Debug Track]", { index, title: track.title, id, idPrefix: id?.slice(0, 16), inCachedTracks: M.cachedTracks.has(id), inTrackCaches: M.trackCaches.has(id), segmentCount: M.trackCaches.get(id)?.size || 0, inTrackBlobs: M.trackBlobs.has(id), bulkStarted: M.bulkDownloadStarted.get(id) }); }; // Clear all caches (for debugging) M.clearAllCaches = async function() { await TrackStorage.clear(); M.cachedTracks.clear(); M.trackCaches.clear(); M.trackBlobs.clear(); M.bulkDownloadStarted.clear(); M.renderQueue(); M.renderLibrary(); console.log("[Cache] All caches cleared. Refresh the page."); }; // Render the current queue M.renderQueue = function() { const container = M.$("#queue"); if (!container) return; container.innerHTML = ""; const canEdit = M.canControl(); // Setup container-level drag handlers for dropping from library if (canEdit) { container.ondragover = (e) => { if (dragSource === 'library') { e.preventDefault(); e.dataTransfer.dropEffect = "copy"; // If no tracks or hovering at bottom, show we can drop if (M.queue.length === 0) { container.classList.add("drop-target"); } } }; container.ondragleave = (e) => { // Only remove if leaving the container entirely if (!container.contains(e.relatedTarget)) { container.classList.remove("drop-target"); } }; container.ondrop = (e) => { container.classList.remove("drop-target"); // Handle drop on empty queue or at the end if (dragSource === 'library' && draggedLibraryIds.length > 0) { e.preventDefault(); const targetIndex = dropTargetIndex !== null ? dropTargetIndex : M.queue.length; insertTracksAtPosition(draggedLibraryIds, targetIndex); draggedLibraryIds = []; dragSource = null; dropTargetIndex = null; } }; } if (M.queue.length === 0) { container.innerHTML = '
Queue empty - drag tracks here
'; return; } // Debug: log first few track cache statuses if (M.queue.length > 0 && M.cachedTracks.size > 0) { const sample = M.queue.slice(0, 3).map(t => { const id = t.id || t.filename; return { title: t.title?.slice(0, 20), id: id?.slice(0, 12), cached: M.cachedTracks.has(id) }; }); console.log("[Queue Render] Sample tracks:", sample, "| cachedTracks sample:", [...M.cachedTracks].slice(0, 3).map(s => s.slice(0, 12))); } M.queue.forEach((track, i) => { const div = document.createElement("div"); const trackId = track.id || track.filename; const isCached = M.cachedTracks.has(trackId); const isSelected = M.selectedQueueIndices.has(i); div.className = "track" + (i === M.currentIndex ? " active" : "") + (isCached ? " cached" : " not-cached") + (isSelected ? " selected" : ""); div.dataset.index = i; const title = track.title?.trim() || (track.id || track.filename || "Unknown").replace(/\.[^.]+$/, ""); const checkmark = isSelected ? `` : ''; const trackNum = `${i + 1}.`; div.innerHTML = `${checkmark}${trackNum}${title}${M.fmt(track.duration)}`; // Drag and drop for reordering (if user can edit) if (canEdit) { div.draggable = true; div.ondragstart = (e) => { dragSource = 'queue'; draggedLibraryIds = []; // If dragging a selected item, drag all selected; otherwise just this one if (M.selectedQueueIndices.has(i)) { draggedIndices = [...M.selectedQueueIndices]; } else { draggedIndices = [i]; } div.classList.add("dragging"); e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", "queue:" + draggedIndices.join(",")); }; div.ondragend = () => { div.classList.remove("dragging"); draggedIndices = []; draggedLibraryIds = []; dragSource = null; // Clear all drop indicators container.querySelectorAll(".drop-above, .drop-below").forEach(el => { el.classList.remove("drop-above", "drop-below"); }); }; div.ondragover = (e) => { e.preventDefault(); e.dataTransfer.dropEffect = "move"; // Determine if dropping above or below const rect = div.getBoundingClientRect(); const midY = rect.top + rect.height / 2; const isAbove = e.clientY < midY; // Clear other indicators container.querySelectorAll(".drop-above, .drop-below").forEach(el => { el.classList.remove("drop-above", "drop-below"); }); // Don't show indicator on dragged queue items (for reorder) if (dragSource === 'queue' && draggedIndices.includes(i)) return; div.classList.add(isAbove ? "drop-above" : "drop-below"); dropTargetIndex = isAbove ? i : i + 1; }; div.ondragleave = () => { div.classList.remove("drop-above", "drop-below"); }; div.ondrop = (e) => { e.preventDefault(); div.classList.remove("drop-above", "drop-below"); if (dragSource === 'library' && draggedLibraryIds.length > 0 && dropTargetIndex !== null) { // Insert library tracks at drop position insertTracksAtPosition(draggedLibraryIds, dropTargetIndex); } else if (dragSource === 'queue' && draggedIndices.length > 0 && dropTargetIndex !== null) { // Reorder queue const minDragged = Math.min(...draggedIndices); const maxDragged = Math.max(...draggedIndices); if (dropTargetIndex < minDragged || dropTargetIndex > maxDragged + 1) { reorderQueue(draggedIndices, dropTargetIndex); } } draggedIndices = []; draggedLibraryIds = []; dragSource = null; dropTargetIndex = null; }; } // Click toggles selection div.onclick = (e) => { if (e.target.closest('.track-actions')) return; M.toggleQueueSelection(i, e.shiftKey); }; // Right-click context menu div.oncontextmenu = (e) => { const menuItems = []; const hasSelection = M.selectedQueueIndices.size > 0; const selectedCount = hasSelection ? M.selectedQueueIndices.size : 1; const indicesToRemove = hasSelection ? [...M.selectedQueueIndices] : [i]; // Play track option (only for single track, not bulk) if (!hasSelection) { menuItems.push({ label: "▶ Play track", action: async () => { if (M.synced && M.currentChannelId) { const res = await fetch("/api/channels/" + M.currentChannelId + "/jump", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ index: i }) }); if (res.status === 403) M.flashPermissionDenied(); } else { M.currentIndex = i; M.currentTrackId = trackId; M.serverTrackDuration = track.duration; M.setTrackTitle(title); M.loadingSegments.clear(); const cachedUrl = await M.loadTrackBlob(trackId); M.audio.src = cachedUrl || M.getTrackUrl(trackId); M.audio.currentTime = 0; M.localTimestamp = 0; M.audio.play(); M.renderQueue(); } } }); } // Remove track(s) option (if user can edit) if (canEdit) { const label = selectedCount > 1 ? `✕ Remove ${selectedCount} tracks` : "✕ Remove track"; menuItems.push({ label, danger: true, action: async () => { if (!M.currentChannelId) return; const res = await fetch("/api/channels/" + M.currentChannelId + "/queue", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ remove: indicesToRemove }) }); if (res.status === 403) M.flashPermissionDenied(); else if (res.ok) { M.showToast(selectedCount > 1 ? `Removed ${selectedCount} tracks` : "Track removed"); M.clearSelections(); } } }); } // Preload track(s) option const idsToPreload = hasSelection ? [...M.selectedQueueIndices].map(idx => M.queue[idx]?.id).filter(Boolean) : [trackId]; const uncachedIds = idsToPreload.filter(id => !M.cachedTracks.has(id)); if (uncachedIds.length > 0) { const preloadLabel = uncachedIds.length > 1 ? `⬇ Preload ${uncachedIds.length} tracks` : "⬇ Preload track"; menuItems.push({ label: preloadLabel, action: () => { M.showToast(`Preloading ${uncachedIds.length} track${uncachedIds.length > 1 ? 's' : ''}...`); uncachedIds.forEach(id => M.downloadAndCacheTrack(id)); } }); } // Clear selection option (if items selected) if (hasSelection) { menuItems.push({ label: "Clear selection", action: () => M.clearSelections() }); } showContextMenu(e, menuItems); }; container.appendChild(div); }); }; // Library search state M.librarySearchQuery = ""; // Render the library M.renderLibrary = function() { const container = M.$("#library"); if (!container) return; container.innerHTML = ""; if (M.library.length === 0) { container.innerHTML = '
No tracks discovered
'; return; } const canEdit = M.canControl(); const query = M.librarySearchQuery.toLowerCase(); // Filter library by search query const filteredLibrary = query ? M.library.map((track, i) => ({ track, i })).filter(({ track }) => { const title = track.title?.trim() || track.filename; return title.toLowerCase().includes(query); }) : M.library.map((track, i) => ({ track, i })); if (filteredLibrary.length === 0) { container.innerHTML = '
No matches
'; return; } filteredLibrary.forEach(({ track, i }) => { const div = document.createElement("div"); const isCached = M.cachedTracks.has(track.id); const isSelected = M.selectedLibraryIds.has(track.id); div.className = "track" + (isCached ? " cached" : " not-cached") + (isSelected ? " selected" : ""); const title = track.title?.trim() || track.filename.replace(/\.[^.]+$/, ""); const checkmark = isSelected ? `` : ''; div.innerHTML = `${checkmark}${title}${M.fmt(track.duration)}`; // Drag from library to queue (if user can edit) if (canEdit) { div.draggable = true; div.ondragstart = (e) => { dragSource = 'library'; draggedIndices = []; // If dragging a selected item, drag all selected; otherwise just this one if (M.selectedLibraryIds.has(track.id)) { draggedLibraryIds = [...M.selectedLibraryIds]; } else { draggedLibraryIds = [track.id]; } div.classList.add("dragging"); e.dataTransfer.effectAllowed = "copy"; e.dataTransfer.setData("text/plain", "library:" + draggedLibraryIds.join(",")); }; div.ondragend = () => { div.classList.remove("dragging"); draggedIndices = []; draggedLibraryIds = []; dragSource = null; // Clear drop indicators in queue const queueContainer = M.$("#queue"); if (queueContainer) { queueContainer.querySelectorAll(".drop-above, .drop-below").forEach(el => { el.classList.remove("drop-above", "drop-below"); }); } }; } // Click toggles selection div.onclick = (e) => { if (e.target.closest('.track-actions')) return; M.toggleLibrarySelection(i, e.shiftKey); }; // Right-click context menu div.oncontextmenu = (e) => { const menuItems = []; const hasSelection = M.selectedLibraryIds.size > 0; const selectedCount = hasSelection ? M.selectedLibraryIds.size : 1; const idsToAdd = hasSelection ? [...M.selectedLibraryIds] : [track.id]; // Play track option (local mode only, single track) if (!M.synced && !hasSelection) { menuItems.push({ label: "▶ Play track", action: async () => { M.currentTrackId = track.id; M.serverTrackDuration = track.duration; M.setTrackTitle(title); M.loadingSegments.clear(); const cachedUrl = await M.loadTrackBlob(track.id); M.audio.src = cachedUrl || M.getTrackUrl(track.id); M.audio.currentTime = 0; M.localTimestamp = 0; M.audio.play(); } }); } // Add to queue option (if user can edit) if (canEdit) { const label = selectedCount > 1 ? `+ Add ${selectedCount} to queue` : "+ Add to queue"; menuItems.push({ label, action: async () => { if (!M.currentChannelId) { M.showToast("No channel selected"); return; } const res = await fetch("/api/channels/" + M.currentChannelId + "/queue", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ add: idsToAdd }) }); if (res.status === 403) M.flashPermissionDenied(); else if (res.ok) { M.showToast(selectedCount > 1 ? `Added ${selectedCount} tracks` : "Track added to queue"); M.clearSelections(); } } }); } // Preload track(s) option const uncachedIds = idsToAdd.filter(id => !M.cachedTracks.has(id)); if (uncachedIds.length > 0) { const preloadLabel = uncachedIds.length > 1 ? `⬇ Preload ${uncachedIds.length} tracks` : "⬇ Preload track"; menuItems.push({ label: preloadLabel, action: () => { M.showToast(`Preloading ${uncachedIds.length} track${uncachedIds.length > 1 ? 's' : ''}...`); uncachedIds.forEach(id => M.downloadAndCacheTrack(id)); } }); } // Clear selection option (if items selected) if (hasSelection) { menuItems.push({ label: "Clear selection", action: () => M.clearSelections() }); } if (menuItems.length > 0) { showContextMenu(e, menuItems); } }; container.appendChild(div); }); }; // Load library from server M.loadLibrary = async function() { try { const res = await fetch("/api/library"); M.library = await res.json(); M.renderLibrary(); } catch (e) { console.warn("Failed to load library"); } }; // Setup library search document.addEventListener("DOMContentLoaded", () => { const searchInput = M.$("#library-search"); if (searchInput) { searchInput.addEventListener("input", (e) => { M.librarySearchQuery = e.target.value; M.renderLibrary(); }); } }); })();