// MusicRoom - Audio Cache module // Track caching, downloading, and prefetching (function() { const M = window.MusicRoom; // Get or create cache for a track M.getTrackCache = function(filename) { if (!filename) return new Set(); if (!M.trackCaches.has(filename)) { M.trackCaches.set(filename, new Set()); } return M.trackCaches.get(filename); }; // Get track URL - prefers cached blob, falls back to API M.getTrackUrl = function(filename) { return M.trackBlobs.get(filename) || "/api/tracks/" + encodeURIComponent(filename); }; // Load a track blob from storage or fetch from server M.loadTrackBlob = async function(filename) { // Check if already in memory if (M.trackBlobs.has(filename)) { return M.trackBlobs.get(filename); } // Check persistent storage const cached = await TrackStorage.get(filename); if (cached) { const blobUrl = URL.createObjectURL(cached.blob); M.trackBlobs.set(filename, blobUrl); // Mark all segments as cached const trackCache = M.getTrackCache(filename); for (let i = 0; i < M.SEGMENTS; i++) trackCache.add(i); M.bulkDownloadStarted.set(filename, true); return blobUrl; } return null; }; // Download and cache a full track M.downloadAndCacheTrack = async function(filename) { if (M.bulkDownloadStarted.get(filename)) return M.trackBlobs.get(filename); M.bulkDownloadStarted.set(filename, true); try { const startTime = performance.now(); const res = await fetch("/api/tracks/" + encodeURIComponent(filename)); const data = await res.arrayBuffer(); const elapsed = (performance.now() - startTime) / 1000; // Mark all segments as cached const trackCache = M.getTrackCache(filename); 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(filename, blobUrl); // Persist to storage await TrackStorage.set(filename, blob, contentType); // 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(filename, false); return null; } }; // Fetch a single segment with range request async function fetchSegment(i, segStart, segEnd) { const trackCache = M.getTrackCache(M.currentFilename); 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(M.currentFilename), { headers: { "Range": `bytes=${byteStart}-${byteEnd}` } }); const data = await res.arrayBuffer(); const elapsed = (performance.now() - startTime) / 1000; // Mark segment as cached trackCache.add(i); // 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 filename = M.currentFilename; if (!filename || M.bulkDownloadStarted.get(filename)) return; const blobUrl = await M.downloadAndCacheTrack(filename); // Switch to blob URL if still on this track if (blobUrl && M.currentFilename === filename && 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.currentFilename || !M.audio.src || M.serverTrackDuration <= 0) return; prefetching = true; const segmentDur = M.serverTrackDuration / M.SEGMENTS; const missingSegments = []; const trackCache = M.getTrackCache(M.currentFilename); // 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.currentFilename)) { 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; }; })();