// MusicRoom - Audio Cache module // Track caching, downloading, and prefetching (function() { const M = window.MusicRoom; // Load stream-only preference from localStorage M.streamOnly = localStorage.getItem("musicroom_streamOnly") === "true"; // Toggle stream-only mode M.setStreamOnly = function(enabled) { M.streamOnly = enabled; localStorage.setItem("musicroom_streamOnly", enabled ? "true" : "false"); M.showToast(enabled ? "Stream-only mode enabled" : "Caching enabled"); }; // Check cache size and prune if over limit M.checkCacheSize = async function() { const stats = await TrackStorage.getStats(); if (stats.totalSize > M.cacheLimit) { console.log(`[Cache] Size ${(stats.totalSize / 1024 / 1024).toFixed(1)}MB exceeds limit ${(M.cacheLimit / 1024 / 1024).toFixed(0)}MB, pruning...`); await M.pruneCache(stats.totalSize - M.cacheLimit + (10 * 1024 * 1024)); // Free extra 10MB } }; // Remove oldest cached tracks to free up space M.pruneCache = async function(bytesToFree) { // Get all cached tracks with metadata const allKeys = await TrackStorage.list(); if (allKeys.length === 0) return; // Get track sizes (we need to fetch each to know size) const trackSizes = []; for (const key of allKeys) { const cached = await TrackStorage.get(key); if (cached && cached.blob) { trackSizes.push({ id: key, size: cached.blob.size }); } } // Sort by... we don't have timestamps, so just remove from start let freed = 0; for (const { id, size } of trackSizes) { if (freed >= bytesToFree) break; await TrackStorage.remove(id); M.cachedTracks.delete(id); freed += size; console.log(`[Cache] Pruned: ${id.slice(0, 16)}... (${(size / 1024 / 1024).toFixed(1)}MB)`); } M.showToast(`Freed ${(freed / 1024 / 1024).toFixed(0)}MB cache space`); M.renderQueue(); M.renderLibrary(); }; // Get or create cache for a track M.getTrackCache = function(trackId) { if (!trackId) return new Set(); if (!M.trackCaches.has(trackId)) { M.trackCaches.set(trackId, new Set()); } return M.trackCaches.get(trackId); }; // Get track URL - prefers cached blob, falls back to API M.getTrackUrl = function(trackId) { return M.trackBlobs.get(trackId) || "/api/tracks/" + encodeURIComponent(trackId); }; // Load a track blob from storage or fetch from server M.loadTrackBlob = async function(trackId) { // Check if already in memory if (M.trackBlobs.has(trackId)) { return M.trackBlobs.get(trackId); } // Check persistent storage const cached = await TrackStorage.get(trackId); if (cached) { const blobUrl = URL.createObjectURL(cached.blob); M.trackBlobs.set(trackId, blobUrl); // Mark all segments as cached const trackCache = M.getTrackCache(trackId); for (let i = 0; i < M.SEGMENTS; i++) trackCache.add(i); M.bulkDownloadStarted.set(trackId, true); // Update cache status indicator if (!M.cachedTracks.has(trackId)) { M.cachedTracks.add(trackId); M.renderQueue(); M.renderLibrary(); } return blobUrl; } return null; }; // Check if track has all segments and trigger full cache if so M.checkAndCacheComplete = function(trackId) { if (!trackId || M.cachedTracks.has(trackId)) return; const trackCache = M.trackCaches.get(trackId); if (trackCache && trackCache.size >= M.SEGMENTS) { console.log("[Cache] Track has all segments, triggering full cache:", trackId.slice(0, 16) + "..."); M.downloadAndCacheTrack(trackId); } }; // Download and cache a full track M.downloadAndCacheTrack = async function(trackId) { if (M.bulkDownloadStarted.get(trackId)) return M.trackBlobs.get(trackId); M.bulkDownloadStarted.set(trackId, true); try { const startTime = performance.now(); const res = await fetch("/api/tracks/" + encodeURIComponent(trackId)); const data = await res.arrayBuffer(); const elapsed = (performance.now() - startTime) / 1000; // Mark all segments as cached const trackCache = M.getTrackCache(trackId); for (let i = 0; i < M.SEGMENTS; i++) trackCache.add(i); // Create blob and URL const contentType = res.headers.get("Content-Type") || "audio/mpeg"; const blob = new Blob([data], { type: contentType }); const blobUrl = URL.createObjectURL(blob); M.trackBlobs.set(trackId, blobUrl); // Persist to storage (handle quota errors gracefully) try { await TrackStorage.set(trackId, blob, contentType); M.cachedTracks.add(trackId); // Check if we need to prune old tracks M.checkCacheSize(); } catch (e) { if (e.name === 'QuotaExceededError') { M.showToast("Cache full - track will stream only", "warning"); } else { console.warn("[Cache] Storage error:", e); } } // Update cache status and re-render lists console.log("[Cache] Track cached:", trackId.slice(0, 16) + "...", "| size:", (data.byteLength / 1024 / 1024).toFixed(2) + "MB"); M.renderQueue(); M.renderLibrary(); // Update download speed if (elapsed > 0 && data.byteLength > 0) { M.recentDownloads.push(data.byteLength / elapsed); if (M.recentDownloads.length > 5) M.recentDownloads.shift(); M.downloadSpeed = M.recentDownloads.reduce((a, b) => a + b, 0) / M.recentDownloads.length; } return blobUrl; } catch (e) { M.bulkDownloadStarted.set(trackId, false); return null; } }; // Fetch a single segment with range request async function fetchSegment(i, segStart, segEnd) { const trackId = M.currentTrackId; const trackCache = M.getTrackCache(trackId); if (M.loadingSegments.has(i) || trackCache.has(i)) return; M.loadingSegments.add(i); try { const byteStart = Math.floor(segStart * M.audioBytesPerSecond); const byteEnd = Math.floor(segEnd * M.audioBytesPerSecond); const startTime = performance.now(); const res = await fetch("/api/tracks/" + encodeURIComponent(trackId), { headers: { "Range": `bytes=${byteStart}-${byteEnd}` } }); const data = await res.arrayBuffer(); const elapsed = (performance.now() - startTime) / 1000; // Mark segment as cached trackCache.add(i); // Check if all segments are now cached - if so, trigger full cache if (trackCache.size >= M.SEGMENTS && !M.cachedTracks.has(trackId)) { console.log("[Cache] All segments complete for:", trackId.slice(0, 16) + "...", "- triggering full cache"); // Download full track to persist to storage M.downloadAndCacheTrack(trackId); } // Update audio bitrate estimate const bytesReceived = data.byteLength; const durationCovered = segEnd - segStart; if (bytesReceived > 0 && durationCovered > 0) { M.audioBytesPerSecond = Math.round(bytesReceived / durationCovered); } // Update download speed (rolling average of last 5 downloads) if (elapsed > 0 && bytesReceived > 0) { M.recentDownloads.push(bytesReceived / elapsed); if (M.recentDownloads.length > 5) M.recentDownloads.shift(); M.downloadSpeed = M.recentDownloads.reduce((a, b) => a + b, 0) / M.recentDownloads.length; } } catch (e) {} M.loadingSegments.delete(i); } // Background bulk download - runs independently async function startBulkDownload() { const trackId = M.currentTrackId; if (!trackId || M.bulkDownloadStarted.get(trackId)) return; const blobUrl = await M.downloadAndCacheTrack(trackId); // Switch to blob URL if still on this track if (blobUrl && M.currentTrackId === trackId && M.audio.src && !M.audio.src.startsWith("blob:")) { const currentTime = M.audio.currentTime; const wasPlaying = !M.audio.paused; M.audio.src = blobUrl; M.audio.currentTime = currentTime; if (wasPlaying) M.audio.play().catch(() => {}); } } // Prefetch missing segments let prefetching = false; M.prefetchSegments = async function() { if (prefetching || !M.currentTrackId || !M.audio.src || M.serverTrackDuration <= 0) return; prefetching = true; const segmentDur = M.serverTrackDuration / M.SEGMENTS; const missingSegments = []; const trackCache = M.getTrackCache(M.currentTrackId); // Find all missing segments (not in audio buffer AND not in our cache) for (let i = 0; i < M.SEGMENTS; i++) { if (trackCache.has(i) || M.loadingSegments.has(i)) continue; const segStart = i * segmentDur; const segEnd = (i + 1) * segmentDur; let available = false; for (let j = 0; j < M.audio.buffered.length; j++) { if (M.audio.buffered.start(j) <= segStart && M.audio.buffered.end(j) >= segEnd) { available = true; break; } } if (!available) { missingSegments.push({ i, segStart, segEnd }); } } if (missingSegments.length > 0) { // Fast connection: also start bulk download in background if (M.downloadSpeed >= M.FAST_THRESHOLD && !M.bulkDownloadStarted.get(M.currentTrackId)) { startBulkDownload(); // Fire and forget } // Always fetch segments one at a time for seek support const s = missingSegments[0]; await fetchSegment(s.i, s.segStart, s.segEnd); } prefetching = false; }; })();