265 lines
9.5 KiB
JavaScript
265 lines
9.5 KiB
JavaScript
// MusicRoom - Audio Cache module
|
|
// Track caching, downloading, and prefetching
|
|
|
|
(function() {
|
|
const M = window.MusicRoom;
|
|
|
|
// Load stream-only preference from localStorage
|
|
M.streamOnly = localStorage.getItem("blastoise_streamOnly") === "true";
|
|
|
|
// Toggle stream-only mode
|
|
M.setStreamOnly = function(enabled) {
|
|
M.streamOnly = enabled;
|
|
localStorage.setItem("blastoise_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;
|
|
};
|
|
})();
|