// 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; // Download state - only one at a time let isDownloading = false; let exportQueue = []; let isExporting = false; // Download a track to user's device (uses cache if available) async function downloadTrack(trackId, filename) { if (isDownloading) { M.showToast("Download already in progress", "warning"); return; } isDownloading = true; M.showToast(`Downloading: ${filename}`); try { let blob = null; // Try to get from cache first if (M.cachedTracks.has(trackId)) { try { const cached = await TrackStorage.get(trackId); if (cached && cached.blob) { blob = cached.blob; } } catch (e) { console.log("Cache miss, fetching from server"); } } // Fall back to fetching from server if (!blob) { const res = await fetch(`/api/tracks/${encodeURIComponent(trackId)}`); if (!res.ok) throw new Error(`Server returned ${res.status}`); blob = await res.blob(); } if (!blob || blob.size === 0) { throw new Error("Empty blob"); } const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); M.showToast(`Downloaded: ${filename}`); } catch (e) { console.error("Download error:", e); M.showToast(`Download failed: ${e.message}`, "error"); } finally { isDownloading = false; } } // Export all cached tracks M.exportAllCached = async function() { if (isExporting) { M.showToast("Export already in progress", "warning"); return; } // Build list of cached tracks with filenames const cachedIds = [...M.cachedTracks]; if (cachedIds.length === 0) { M.showToast("No cached tracks to export", "warning"); return; } // Find filenames from library or queue const trackMap = new Map(); M.library.forEach(t => { if (t.filename) trackMap.set(t.id, t.filename); }); M.queue.forEach(t => { if (t.filename && !trackMap.has(t.id)) trackMap.set(t.id, t.filename); }); // Only export tracks with known filenames exportQueue = cachedIds .filter(id => trackMap.has(id)) .map(id => ({ id, filename: trackMap.get(id) })); const skipped = cachedIds.length - exportQueue.length; if (exportQueue.length === 0) { M.showToast("No exportable tracks (filenames unknown)", "warning"); return; } isExporting = true; const msg = skipped > 0 ? `Exporting ${exportQueue.length} tracks (${skipped} skipped - not in library)` : `Exporting ${exportQueue.length} cached tracks...`; M.showToast(msg); let exported = 0; for (const { id, filename } of exportQueue) { if (!isExporting) break; // Allow cancellation try { const cached = await TrackStorage.get(id); if (cached && cached.blob) { const url = URL.createObjectURL(cached.blob); const a = document.createElement("a"); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); exported++; // Small delay between downloads to not overwhelm browser await new Promise(r => setTimeout(r, 500)); } } catch (e) { console.error(`Export error for ${filename}:`, e); } } isExporting = false; exportQueue = []; M.showToast(`Exported ${exported} tracks`); }; M.cancelExport = function() { if (isExporting) { isExporting = false; M.showToast("Export cancelled"); } }; // 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
'; M.updateNowPlayingBar(); 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(/\.[^.]+$/, ""); div.title = title; // Tooltip for full name 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) { // Get track IDs for the selected indices const idsToAdd = hasSelection ? [...M.selectedQueueIndices].map(idx => M.queue[idx]?.id).filter(Boolean) : [trackId]; // Add again option - duplicate tracks at end of queue const addAgainLabel = selectedCount > 1 ? `+ Add ${selectedCount} again` : "+ Add again"; menuItems.push({ label: addAgainLabel, 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({ add: idsToAdd }) }); if (res.status === 403) M.flashPermissionDenied(); else if (res.ok) { M.showToast(selectedCount > 1 ? `Added ${selectedCount} tracks again` : "Track added again"); M.clearSelections(); } } }); // Play next option - insert after current track const playNextLabel = selectedCount > 1 ? `⏭ Play ${selectedCount} next` : "⏭ Play next"; menuItems.push({ label: playNextLabel, action: async () => { if (!M.currentChannelId) return; const insertAt = (M.currentIndex ?? 0) + 1; const res = await fetch("/api/channels/" + M.currentChannelId + "/queue", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ add: idsToAdd, insertAt }) }); if (res.status === 403) M.flashPermissionDenied(); else if (res.ok) { M.showToast(selectedCount > 1 ? `${selectedCount} tracks playing next` : "Track playing next"); M.clearSelections(); } } }); 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 - only show if not in stream-only mode if (!M.streamOnly) { const idsToPreload = hasSelection ? [...M.selectedQueueIndices].map(idx => M.queue[idx]?.id).filter(Boolean) : [trackId]; const preloadLabel = hasSelection && idsToPreload.length > 1 ? `Preload ${idsToPreload.length} tracks` : "Preload track"; menuItems.push({ label: preloadLabel, action: () => { const uncachedIds = idsToPreload.filter(id => !M.cachedTracks.has(id)); if (uncachedIds.length === 0) { M.showToast("All tracks already cached"); return; } M.showToast(`Preloading ${uncachedIds.length} track${uncachedIds.length > 1 ? 's' : ''}...`); uncachedIds.forEach(id => M.downloadAndCacheTrack(id)); } }); } // Download track option (single track only) if (!hasSelection) { menuItems.push({ label: "Download", action: () => downloadTrack(trackId, track.filename) }); // Copy link option menuItems.push({ label: "🔗 Generate listening link", action: () => { const url = `${location.origin}/listen/${encodeURIComponent(trackId)}`; navigator.clipboard.writeText(url).then(() => { M.showToast("Link copied to clipboard"); }).catch(() => { M.showToast("Failed to copy link", "error"); }); } }); } // Clear selection option (if items selected) if (hasSelection) { menuItems.push({ label: "Clear selection", action: () => M.clearSelections() }); } showContextMenu(e, menuItems); }; container.appendChild(div); }); M.updateNowPlayingBar(); }; // Update the now-playing bar above the queue M.updateNowPlayingBar = function() { const bar = M.$("#now-playing-bar"); if (!bar) return; const track = M.queue[M.currentIndex]; if (!track) { bar.classList.add("hidden"); return; } const title = track.title?.trim() || (track.id || track.filename || "Unknown").replace(/\.[^.]+$/, ""); bar.innerHTML = `Now playing: ${title}`; bar.title = title; bar.classList.remove("hidden"); }; // Scroll queue to current track M.scrollToCurrentTrack = function() { const container = M.$("#queue"); if (!container) return; const activeTrack = container.querySelector(".track.active"); if (activeTrack) { activeTrack.scrollIntoView({ behavior: "smooth", block: "center" }); } }; // Setup now-playing bar click handler document.addEventListener("DOMContentLoaded", () => { const bar = M.$("#now-playing-bar"); if (bar) { bar.onclick = () => M.scrollToCurrentTrack(); } }); // 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(/\.[^.]+$/, ""); div.title = title; // Tooltip for full name 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(); } } }); // Play next option - insert after current track const playNextLabel = selectedCount > 1 ? `⏭ Play ${selectedCount} next` : "⏭ Play next"; menuItems.push({ label: playNextLabel, action: async () => { if (!M.currentChannelId) { M.showToast("No channel selected"); return; } const insertAt = (M.currentIndex ?? 0) + 1; const res = await fetch("/api/channels/" + M.currentChannelId + "/queue", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ add: idsToAdd, insertAt }) }); if (res.status === 403) M.flashPermissionDenied(); else if (res.ok) { M.showToast(selectedCount > 1 ? `${selectedCount} tracks playing next` : "Track playing next"); M.clearSelections(); } } }); } // Preload track(s) option - only show if not in stream-only mode if (!M.streamOnly) { const preloadLabel = selectedCount > 1 ? `Preload ${selectedCount} tracks` : "Preload track"; menuItems.push({ label: preloadLabel, action: () => { const uncachedIds = idsToAdd.filter(id => !M.cachedTracks.has(id)); if (uncachedIds.length === 0) { M.showToast("All tracks already cached"); return; } M.showToast(`Preloading ${uncachedIds.length} track${uncachedIds.length > 1 ? 's' : ''}...`); uncachedIds.forEach(id => M.downloadAndCacheTrack(id)); } }); } // Download track option (single track only) if (!hasSelection) { menuItems.push({ label: "Download", action: () => downloadTrack(track.id, track.filename) }); // Copy link option menuItems.push({ label: "🔗 Generate listening link", action: () => { const url = `${location.origin}/listen/${encodeURIComponent(track.id)}`; navigator.clipboard.writeText(url).then(() => { M.showToast("Link copied to clipboard"); }).catch(() => { M.showToast("Failed to copy link", "error"); }); } }); } // Export all cached option (if there are cached tracks) if (M.cachedTracks.size > 0) { menuItems.push({ label: `Preload and export ${M.cachedTracks.size} cached`, action: () => M.exportAllCached() }); } // 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(); }); } }); })();