initial caching implementation
This commit is contained in:
parent
8e9835a61d
commit
cbd8a46a98
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,3 +1,2 @@
|
|||||||
alby.html
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.old/
|
.old/
|
||||||
241
index.html
241
index.html
@ -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');
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user