// MusicRoom - Queue module // Queue and library display using trackContainer (function() { const M = window.MusicRoom; // Download state - only one at a time let isDownloading = false; let exportQueue = []; let isExporting = false; // Container instances let queueContainer = null; let libraryContainer = null; // Library search state M.librarySearchQuery = ""; // 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; } const cachedIds = [...M.cachedTracks]; if (cachedIds.length === 0) { M.showToast("No cached tracks to export", "warning"); return; } 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); }); exportQueue = cachedIds .filter(id => trackMap.has(id)) .map(id => ({ id, filename: trackMap.get(id) })); if (exportQueue.length === 0) { M.showToast("No exportable tracks found", "warning"); return; } isExporting = true; M.showToast(`Exporting ${exportQueue.length} tracks...`); let exported = 0; for (const { id, filename } of exportQueue) { if (!isExporting) break; 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++; 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"); } }; // Update cache status for all tracks M.updateCacheStatus = async function() { const cached = await TrackStorage.list(); // Migration: remove old filename-based cache entries const oldEntries = cached.filter(id => !id.startsWith("sha256:")); if (oldEntries.length > 0) { console.log("[Cache] Migrating: removing", oldEntries.length, "old entries"); for (const oldId of oldEntries) { await TrackStorage.remove(oldId); } const updated = await TrackStorage.list(); M.cachedTracks = new Set(updated); } else { M.cachedTracks = new Set(cached); } console.log("[Cache] Updated:", M.cachedTracks.size, "tracks cached"); }; // Debug functions M.debugCacheStatus = function() { if (!M.currentTrackId) { console.log("[Cache Debug] No current track"); return; } const trackCache = M.getTrackCache(M.currentTrackId); console.log("[Cache Debug]", { trackId: M.currentTrackId.slice(0, 16) + "...", segments: `${trackCache.size}/${M.SEGMENTS}`, inCachedTracks: M.cachedTracks.has(M.currentTrackId), hasBlobUrl: M.trackBlobs.has(M.currentTrackId) }); }; M.debugCacheMismatch = function() { console.log("[Cache Mismatch Debug]"); console.log("M.cachedTracks:", M.cachedTracks); console.log("M.queue tracks:"); M.queue.forEach((t, i) => { const id = t.id || t.filename; console.log(` [${i}] ${t.title?.slice(0, 30)} | cached: ${M.cachedTracks.has(id)}`); }); }; 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] Cleared. Refresh the page."); }; // Initialize containers function initContainers() { const queueEl = M.$("#queue"); const libraryEl = M.$("#library"); if (queueEl && !queueContainer) { queueContainer = M.trackContainer.createContainer({ type: 'queue', element: queueEl, getTracks: () => M.queue, canReorder: true, onRender: () => M.updateNowPlayingBar() }); } if (libraryEl && !libraryContainer) { libraryContainer = M.trackContainer.createContainer({ type: 'library', element: libraryEl, getTracks: () => M.library, getFilteredTracks: () => { const query = M.librarySearchQuery.toLowerCase(); if (!query) { return M.library.map((track, i) => ({ track, originalIndex: i })); } return M.library .map((track, i) => ({ track, originalIndex: i })) .filter(({ track }) => { const title = track.title?.trim() || track.filename || ''; return title.toLowerCase().includes(query); }); } }); } } // Render functions M.renderQueue = function() { initContainers(); if (queueContainer) { queueContainer.render(); } updateQueueDuration(); }; function updateQueueDuration() { const el = M.$("#queue-duration"); if (!el) return; const totalSecs = M.queue.reduce((sum, t) => sum + (t.duration || 0), 0); if (totalSecs === 0) { el.textContent = ""; return; } const hours = Math.floor(totalSecs / 3600); const mins = Math.floor((totalSecs % 3600) / 60); const secs = Math.floor(totalSecs % 60); let text = ""; if (hours > 0) text = `${hours}h ${mins}m`; else if (mins > 0) text = `${mins}m ${secs}s`; else text = `${secs}s`; el.textContent = `(${M.queue.length} tracks ยท ${text})`; } M.renderLibrary = function() { initContainers(); if (libraryContainer) { libraryContainer.render(); } }; // Now-playing bar 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"); }; M.scrollToCurrentTrack = function() { const container = M.$("#queue"); if (!container) return; const activeTrack = container.querySelector(".track.active"); if (activeTrack) { activeTrack.scrollIntoView({ behavior: "smooth", block: "center" }); } }; // Backwards compatibility M.clearSelections = function() { M.clearAllSelections(); M.renderQueue(); M.renderLibrary(); }; // 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 event listeners document.addEventListener("DOMContentLoaded", () => { const bar = M.$("#now-playing-bar"); if (bar) { bar.onclick = () => M.scrollToCurrentTrack(); } const searchInput = M.$("#library-search"); if (searchInput) { searchInput.addEventListener("input", (e) => { M.librarySearchQuery = e.target.value; M.renderLibrary(); }); } }); })();