diff --git a/.gitignore b/.gitignore index 1bf264d..a350a7b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,2 @@ -alby.html .DS_Store .old/ \ No newline at end of file diff --git a/no-damage.html b/archive/no-damage.html similarity index 100% rename from no-damage.html rename to archive/no-damage.html diff --git a/index.html b/index.html index 627196c..3d6f0ce 100644 --- a/index.html +++ b/index.html @@ -1462,6 +1462,196 @@ // 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} + */ + 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} 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} 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} 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 // Uses mousedown for immediate tactile feedback @@ -1545,15 +1735,28 @@ } // Load initial track - function loadTrack(index) { + // Now async to support cache-first loading + async function loadTrack(index) { currentTrack = index; - audio.src = playlist[index].url; // Update the inner span for marquee scrolling trackNameInner.textContent = playlist[index].name; // Reset scroll position for new track 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); // Set initial volume @@ -1615,8 +1818,8 @@ tapeLeft.classList.add('spinning'); tapeRight.classList.add('spinning'); - // Delay before loading new track - setTimeout(() => { + // Delay before loading new track (async for cache support) + setTimeout(async () => { // Reset animation speed and playback rate reelLeft.style.animationDuration = ''; reelRight.style.animationDuration = ''; @@ -1625,10 +1828,19 @@ audio.playbackRate = 1; currentTrack = (currentTrack - 1 + playlist.length) % playlist.length; - audio.src = playlist[currentTrack].url; trackNameInner.textContent = playlist[currentTrack].name; resetTitleScroll(); // Reset title 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.oncanplay = function() { if (wasPlaying) { @@ -1677,8 +1889,8 @@ tapeLeft.classList.add('spinning'); tapeRight.classList.add('spinning'); - // Delay before loading new track - setTimeout(() => { + // Delay before loading new track (async for cache support) + setTimeout(async () => { // Reset animation speed and playback rate reelLeft.style.animationDuration = ''; reelRight.style.animationDuration = ''; @@ -1687,10 +1899,19 @@ audio.playbackRate = 1; currentTrack = (currentTrack + 1) % playlist.length; - audio.src = playlist[currentTrack].url; trackNameInner.textContent = playlist[currentTrack].name; resetTitleScroll(); // Reset title 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.oncanplay = function() { if (wasPlaying) { @@ -1812,9 +2033,9 @@ document.addEventListener('touchend', endDrag); // Auto-play next track when current ends - audio.addEventListener('ended', () => { + audio.addEventListener('ended', async () => { currentTrack = (currentTrack + 1) % playlist.length; - loadTrack(currentTrack); + await loadTrack(currentTrack); resetTapeSizes(); audio.play(); reelLeft.classList.add('spinning');