initial caching implementation

This commit is contained in:
cottongin 2026-01-17 11:05:06 -05:00
parent 8e9835a61d
commit cbd8a46a98
No known key found for this signature in database
GPG Key ID: 0ECC91FE4655C262
3 changed files with 231 additions and 11 deletions

1
.gitignore vendored
View File

@ -1,3 +1,2 @@
alby.html
.DS_Store .DS_Store
.old/ .old/

View File

@ -1462,6 +1462,196 @@
// SOUND EFFECTS MODULE - END // SOUND EFFECTS MODULE - END
// ======================================== // ========================================
// ========================================
// TRACK CACHE MODULE - START
// Caches audio files locally using Cache API
// Uses HEAD requests to detect content changes
// ========================================
const TrackCache = {
CACHE_NAME: 'cassette-player-audio-v1',
META_KEY: 'track-cache-meta',
// Fallback cache duration (1 hour) if no ETag/Last-Modified
FALLBACK_MAX_AGE: 60 * 60 * 1000,
cache: null,
/**
* Initialize the cache - opens Cache API storage
* @returns {Promise<Cache>}
*/
async init() {
if (!this.cache) {
this.cache = await caches.open(this.CACHE_NAME);
}
return this.cache;
},
/**
* Get metadata for all cached tracks from localStorage
* @returns {Object} Map of URL -> { etag, lastModified, cachedAt }
*/
getMetadata() {
try {
const data = localStorage.getItem(this.META_KEY);
return data ? JSON.parse(data) : {};
} catch (e) {
console.warn('TrackCache: Failed to read metadata', e);
return {};
}
},
/**
* Save metadata for a track
* @param {string} url - Track URL
* @param {Object} meta - { etag, lastModified, cachedAt }
*/
saveMetadata(url, meta) {
try {
const allMeta = this.getMetadata();
allMeta[url] = meta;
localStorage.setItem(this.META_KEY, JSON.stringify(allMeta));
} catch (e) {
console.warn('TrackCache: Failed to save metadata', e);
}
},
/**
* Check if cached content is stale using HEAD request
* @param {string} url - Track URL
* @returns {Promise<boolean>} True if content has changed, false if still valid
*/
async isStale(url) {
const meta = this.getMetadata()[url];
if (!meta) return true; // No metadata = treat as stale
try {
const response = await fetch(url, { method: 'HEAD' });
if (!response.ok) {
// HEAD failed, assume cached version is still valid
console.log('TrackCache: HEAD request failed, using cached version');
return false;
}
const newEtag = response.headers.get('ETag');
const newLastModified = response.headers.get('Last-Modified');
// Check ETag first (most reliable)
if (newEtag && meta.etag) {
const isChanged = newEtag !== meta.etag;
if (isChanged) {
console.log(`TrackCache: ETag changed for ${url}`);
}
return isChanged;
}
// Fall back to Last-Modified
if (newLastModified && meta.lastModified) {
const isChanged = newLastModified !== meta.lastModified;
if (isChanged) {
console.log(`TrackCache: Last-Modified changed for ${url}`);
}
return isChanged;
}
// No ETag or Last-Modified, use time-based fallback
const age = Date.now() - meta.cachedAt;
const isExpired = age > this.FALLBACK_MAX_AGE;
if (isExpired) {
console.log(`TrackCache: Cache expired (age: ${Math.round(age/1000)}s) for ${url}`);
}
return isExpired;
} catch (e) {
// Network error or CORS issue - assume cached version is valid
console.log('TrackCache: HEAD request error, using cached version', e.message);
return false;
}
},
/**
* Fetch track from network and store in cache
* @param {string} url - Track URL
* @returns {Promise<string>} Blob URL for the audio
*/
async fetchAndCache(url) {
console.log(`TrackCache: Fetching ${url}`);
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch track: ${response.status}`);
}
// Extract cache validation headers
const etag = response.headers.get('ETag');
const lastModified = response.headers.get('Last-Modified');
// Clone response before consuming it
const responseToCache = response.clone();
// Store in cache
await this.init();
await this.cache.put(url, responseToCache);
// Save metadata
this.saveMetadata(url, {
etag: etag,
lastModified: lastModified,
cachedAt: Date.now()
});
console.log(`TrackCache: Cached ${url} (ETag: ${etag || 'none'}, Last-Modified: ${lastModified || 'none'})`);
// Return blob URL for playback
const blob = await response.blob();
return URL.createObjectURL(blob);
},
/**
* Get track - main entry point
* Checks cache, validates with HEAD, returns blob URL
* @param {string} url - Track URL
* @returns {Promise<string>} Blob URL for the audio
*/
async getTrack(url) {
await this.init();
// Check if we have it cached
const cachedResponse = await this.cache.match(url);
if (cachedResponse) {
// Check if content has changed
const stale = await this.isStale(url);
if (!stale) {
console.log(`TrackCache: Using cached version of ${url}`);
const blob = await cachedResponse.blob();
return URL.createObjectURL(blob);
}
// Content changed, fetch new version
console.log(`TrackCache: Content changed, re-fetching ${url}`);
}
// Not cached or stale, fetch from network
return await this.fetchAndCache(url);
},
/**
* Clear all cached tracks
* Useful for debugging or freeing storage
*/
async clearAll() {
await caches.delete(this.CACHE_NAME);
localStorage.removeItem(this.META_KEY);
this.cache = null;
console.log('TrackCache: Cleared all cached tracks');
}
};
// ========================================
// TRACK CACHE MODULE - END
// ========================================
// ======================================== // ========================================
// BUTTON CLICK SOUNDS - Add to all buttons // BUTTON CLICK SOUNDS - Add to all buttons
// Uses mousedown for immediate tactile feedback // Uses mousedown for immediate tactile feedback
@ -1545,15 +1735,28 @@
} }
// Load initial track // Load initial track
function loadTrack(index) { // Now async to support cache-first loading
async function loadTrack(index) {
currentTrack = index; currentTrack = index;
audio.src = playlist[index].url;
// Update the inner span for marquee scrolling // Update the inner span for marquee scrolling
trackNameInner.textContent = playlist[index].name; trackNameInner.textContent = playlist[index].name;
// Reset scroll position for new track // Reset scroll position for new track
resetTitleScroll(); resetTitleScroll();
try {
// Get track from cache or network
const blobUrl = await TrackCache.getTrack(playlist[index].url);
audio.src = blobUrl;
} catch (e) {
// Caching may fail due to CORS if audio is hosted on different origin
// without Access-Control-Allow-Origin headers
console.warn('TrackCache: Caching unavailable (likely CORS), using direct URL');
// Fall back to direct URL - audio element can still play cross-origin
audio.src = playlist[index].url;
}
} }
// Initial track load (async, no await needed for initial load)
loadTrack(0); loadTrack(0);
// Set initial volume // Set initial volume
@ -1615,8 +1818,8 @@
tapeLeft.classList.add('spinning'); tapeLeft.classList.add('spinning');
tapeRight.classList.add('spinning'); tapeRight.classList.add('spinning');
// Delay before loading new track // Delay before loading new track (async for cache support)
setTimeout(() => { setTimeout(async () => {
// Reset animation speed and playback rate // Reset animation speed and playback rate
reelLeft.style.animationDuration = ''; reelLeft.style.animationDuration = '';
reelRight.style.animationDuration = ''; reelRight.style.animationDuration = '';
@ -1625,10 +1828,19 @@
audio.playbackRate = 1; audio.playbackRate = 1;
currentTrack = (currentTrack - 1 + playlist.length) % playlist.length; currentTrack = (currentTrack - 1 + playlist.length) % playlist.length;
audio.src = playlist[currentTrack].url;
trackNameInner.textContent = playlist[currentTrack].name; trackNameInner.textContent = playlist[currentTrack].name;
resetTitleScroll(); // Reset title for new track resetTitleScroll(); // Reset title for new track
resetTapeSizes(); // Reset tape for new track resetTapeSizes(); // Reset tape for new track
// Load from cache or network
try {
const blobUrl = await TrackCache.getTrack(playlist[currentTrack].url);
audio.src = blobUrl;
} catch (e) {
console.warn('TrackCache: Caching unavailable, using direct URL');
audio.src = playlist[currentTrack].url;
}
audio.load(); audio.load();
audio.oncanplay = function() { audio.oncanplay = function() {
if (wasPlaying) { if (wasPlaying) {
@ -1677,8 +1889,8 @@
tapeLeft.classList.add('spinning'); tapeLeft.classList.add('spinning');
tapeRight.classList.add('spinning'); tapeRight.classList.add('spinning');
// Delay before loading new track // Delay before loading new track (async for cache support)
setTimeout(() => { setTimeout(async () => {
// Reset animation speed and playback rate // Reset animation speed and playback rate
reelLeft.style.animationDuration = ''; reelLeft.style.animationDuration = '';
reelRight.style.animationDuration = ''; reelRight.style.animationDuration = '';
@ -1687,10 +1899,19 @@
audio.playbackRate = 1; audio.playbackRate = 1;
currentTrack = (currentTrack + 1) % playlist.length; currentTrack = (currentTrack + 1) % playlist.length;
audio.src = playlist[currentTrack].url;
trackNameInner.textContent = playlist[currentTrack].name; trackNameInner.textContent = playlist[currentTrack].name;
resetTitleScroll(); // Reset title for new track resetTitleScroll(); // Reset title for new track
resetTapeSizes(); // Reset tape for new track resetTapeSizes(); // Reset tape for new track
// Load from cache or network
try {
const blobUrl = await TrackCache.getTrack(playlist[currentTrack].url);
audio.src = blobUrl;
} catch (e) {
console.warn('TrackCache: Caching unavailable, using direct URL');
audio.src = playlist[currentTrack].url;
}
audio.load(); audio.load();
audio.oncanplay = function() { audio.oncanplay = function() {
if (wasPlaying) { if (wasPlaying) {
@ -1812,9 +2033,9 @@
document.addEventListener('touchend', endDrag); document.addEventListener('touchend', endDrag);
// Auto-play next track when current ends // Auto-play next track when current ends
audio.addEventListener('ended', () => { audio.addEventListener('ended', async () => {
currentTrack = (currentTrack + 1) % playlist.length; currentTrack = (currentTrack + 1) % playlist.length;
loadTrack(currentTrack); await loadTrack(currentTrack);
resetTapeSizes(); resetTapeSizes();
audio.play(); audio.play();
reelLeft.classList.add('spinning'); reelLeft.classList.add('spinning');