- Optimized mobile image loading (180px vs 200px desktop) - Fixed Install App navigation not working on desktop - Fixed replaceChild null error in hero rendering - Added PWA icon (512x512) - Fixed back button navigation issues - Added mobile bottom padding for nav bar - Moved Get App FAB higher to avoid nav overlap - Removed unnecessary pushState from video navigation - Made Search/MyList tabs not scroll to top on mobile - Removed duplicate Android TV section from download page
203 lines
5.7 KiB
JavaScript
203 lines
5.7 KiB
JavaScript
/**
|
|
* Image Cache Service
|
|
* Caches movie posters and thumbnails for faster loading
|
|
*/
|
|
|
|
const IMAGE_CACHE_NAME = 'kvstream-images-v1';
|
|
const IMAGE_CACHE_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
const IMAGE_CACHE_MAX_ITEMS = 500;
|
|
|
|
class ImageCacheService {
|
|
constructor() {
|
|
this.memoryCache = new Map();
|
|
this.cacheEnabled = 'caches' in window;
|
|
this.pendingRequests = new Map();
|
|
}
|
|
|
|
/**
|
|
* Get cached image or fetch and cache it
|
|
* @param {string} url - Image URL
|
|
* @returns {Promise<string>} - Blob URL for the image
|
|
*/
|
|
async getCachedImage(url) {
|
|
if (!url || !this.cacheEnabled) return url;
|
|
|
|
// Check memory cache first (fastest)
|
|
if (this.memoryCache.has(url)) {
|
|
return this.memoryCache.get(url);
|
|
}
|
|
|
|
// Deduplicate pending requests
|
|
if (this.pendingRequests.has(url)) {
|
|
return this.pendingRequests.get(url);
|
|
}
|
|
|
|
const fetchPromise = this._fetchAndCache(url);
|
|
this.pendingRequests.set(url, fetchPromise);
|
|
|
|
try {
|
|
const result = await fetchPromise;
|
|
return result;
|
|
} finally {
|
|
this.pendingRequests.delete(url);
|
|
}
|
|
}
|
|
|
|
async _fetchAndCache(url) {
|
|
try {
|
|
const cache = await caches.open(IMAGE_CACHE_NAME);
|
|
|
|
// Check cache first
|
|
const cachedResponse = await cache.match(url);
|
|
if (cachedResponse) {
|
|
const blob = await cachedResponse.blob();
|
|
const blobUrl = URL.createObjectURL(blob);
|
|
this.memoryCache.set(url, blobUrl);
|
|
return blobUrl;
|
|
}
|
|
|
|
// Fetch and cache
|
|
const response = await fetch(url, { mode: 'cors', credentials: 'omit' });
|
|
if (response.ok) {
|
|
const responseClone = response.clone();
|
|
cache.put(url, responseClone);
|
|
|
|
const blob = await response.blob();
|
|
const blobUrl = URL.createObjectURL(blob);
|
|
this.memoryCache.set(url, blobUrl);
|
|
|
|
// Cleanup old cache entries periodically
|
|
this._cleanupCache(cache);
|
|
|
|
return blobUrl;
|
|
}
|
|
} catch (error) {
|
|
// Silent fail - return original URL
|
|
console.warn('Image cache failed:', url);
|
|
}
|
|
|
|
return url;
|
|
}
|
|
|
|
/**
|
|
* Preload images for faster display
|
|
* @param {string[]} urls - Array of image URLs to preload
|
|
*/
|
|
async preloadImages(urls) {
|
|
if (!urls || urls.length === 0) return;
|
|
|
|
// Batch preload with limited concurrency
|
|
const batchSize = 6;
|
|
for (let i = 0; i < urls.length; i += batchSize) {
|
|
const batch = urls.slice(i, i + batchSize);
|
|
await Promise.allSettled(batch.map(url => this.getCachedImage(url)));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create optimized image element with lazy loading and caching
|
|
* @param {string} url - Image source URL
|
|
* @param {string} alt - Alt text
|
|
* @param {string} className - CSS class
|
|
* @returns {HTMLImageElement}
|
|
*/
|
|
createCachedImage(url, alt = '', className = '') {
|
|
const img = document.createElement('img');
|
|
img.alt = alt;
|
|
img.className = className;
|
|
img.loading = 'lazy';
|
|
img.decoding = 'async';
|
|
|
|
// Set placeholder first
|
|
img.src = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 450"%3E%3Crect fill="%23222"%3E%3C/rect%3E%3C/svg%3E';
|
|
|
|
// Then load cached image
|
|
if (url) {
|
|
this.getCachedImage(url).then(cachedUrl => {
|
|
img.src = cachedUrl;
|
|
});
|
|
}
|
|
|
|
return img;
|
|
}
|
|
|
|
/**
|
|
* Cleanup old cache entries
|
|
*/
|
|
async _cleanupCache(cache) {
|
|
try {
|
|
const keys = await cache.keys();
|
|
if (keys.length > IMAGE_CACHE_MAX_ITEMS) {
|
|
// Remove oldest 20% of entries
|
|
const toRemove = Math.floor(keys.length * 0.2);
|
|
for (let i = 0; i < toRemove; i++) {
|
|
await cache.delete(keys[i]);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
// Ignore cleanup errors
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clear all cached images
|
|
*/
|
|
async clearCache() {
|
|
this.memoryCache.clear();
|
|
if (this.cacheEnabled) {
|
|
await caches.delete(IMAGE_CACHE_NAME);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get cache statistics
|
|
*/
|
|
async getCacheStats() {
|
|
const stats = {
|
|
memoryItems: this.memoryCache.size,
|
|
cacheItems: 0,
|
|
cacheSize: 0
|
|
};
|
|
|
|
if (this.cacheEnabled) {
|
|
try {
|
|
const cache = await caches.open(IMAGE_CACHE_NAME);
|
|
const keys = await cache.keys();
|
|
stats.cacheItems = keys.length;
|
|
} catch (e) { }
|
|
}
|
|
|
|
return stats;
|
|
}
|
|
}
|
|
|
|
// Export singleton instance
|
|
export const imageCache = new ImageCacheService();
|
|
|
|
// Auto-preload visible images on scroll
|
|
let preloadObserver = null;
|
|
|
|
export function setupImagePreloading() {
|
|
if (preloadObserver) return;
|
|
|
|
preloadObserver = new IntersectionObserver((entries) => {
|
|
const urls = entries
|
|
.filter(e => e.isIntersecting)
|
|
.map(e => e.target.dataset.src || e.target.src)
|
|
.filter(Boolean);
|
|
|
|
if (urls.length > 0) {
|
|
imageCache.preloadImages(urls);
|
|
}
|
|
}, {
|
|
rootMargin: '200px',
|
|
threshold: 0
|
|
});
|
|
|
|
// Observe all images with data-src or src
|
|
document.querySelectorAll('img[data-src], img[src]').forEach(img => {
|
|
preloadObserver.observe(img);
|
|
});
|
|
}
|
|
|
|
export default imageCache;
|