diff --git a/src/components/common/OptimizedImage.tsx b/src/components/common/OptimizedImage.tsx index 0d9756d..bf3e65a 100644 --- a/src/components/common/OptimizedImage.tsx +++ b/src/components/common/OptimizedImage.tsx @@ -1,7 +1,6 @@ import React, { useState, useEffect, useRef, useCallback } from 'react'; import { View, StyleSheet, Dimensions } from 'react-native'; -import { Image as ExpoImage } from 'expo-image'; -import { imageCacheService } from '../../services/imageCacheService'; +import FastImage from '@d11/react-native-fast-image'; import { logger } from '../../utils/logger'; interface OptimizedImageProps { @@ -59,16 +58,14 @@ const OptimizedImage: React.FC = ({ onLoad, onError, contentFit = 'cover', - transition = 200, + transition = 0, cachePolicy = 'memory' }) => { const [isLoaded, setIsLoaded] = useState(false); const [hasError, setHasError] = useState(false); const [isVisible, setIsVisible] = useState(!lazy); - const [recyclingKey] = useState(() => `${Math.random().toString(36).slice(2)}-${Date.now()}`); const [optimizedUrl, setOptimizedUrl] = useState(''); const mountedRef = useRef(true); - const loadTimeoutRef = useRef(null); // Extract URL from source const sourceUrl = typeof source === 'string' ? source : source?.uri || ''; @@ -80,9 +77,6 @@ const OptimizedImage: React.FC = ({ useEffect(() => { return () => { mountedRef.current = false; - if (loadTimeoutRef.current) { - clearTimeout(loadTimeoutRef.current); - } }; }, []); @@ -97,7 +91,6 @@ const OptimizedImage: React.FC = ({ // Lazy loading intersection observer simulation useEffect(() => { if (lazy && !isVisible) { - // Simple lazy loading - load after a short delay to simulate intersection const timer = setTimeout(() => { if (mountedRef.current) { setIsVisible(true); @@ -108,41 +101,22 @@ const OptimizedImage: React.FC = ({ } }, [lazy, isVisible, priority]); - // Preload image with caching + // Preload image via FastImage const preloadImage = useCallback(async () => { if (!optimizedUrl || !isVisible) return; try { - // Use our cache service to manage the image - const cachedUrl = await imageCacheService.getCachedImageUrl(optimizedUrl); - - // Set a timeout for loading - loadTimeoutRef.current = setTimeout(() => { - if (mountedRef.current && !isLoaded) { - logger.warn(`[OptimizedImage] Load timeout for: ${optimizedUrl.substring(0, 50)}...`); - setHasError(true); - } - }, 10000); // 10 second timeout - - // Skip prefetch to reduce memory pressure and heating - // await ExpoImage.prefetch(cachedUrl); - - if (mountedRef.current) { - setIsLoaded(true); - if (loadTimeoutRef.current) { - clearTimeout(loadTimeoutRef.current); - loadTimeoutRef.current = null; - } - onLoad?.(); - } + await FastImage.preload([{ uri: optimizedUrl }]); + if (!mountedRef.current) return; + setIsLoaded(true); + onLoad?.(); } catch (error) { - if (mountedRef.current) { - logger.error(`[OptimizedImage] Failed to load: ${optimizedUrl.substring(0, 50)}...`, error); - setHasError(true); - onError?.(error); - } + if (!mountedRef.current) return; + logger.error(`[OptimizedImage] Failed to preload: ${optimizedUrl.substring(0, 50)}...`, error); + setHasError(true); + onError?.(error); } - }, [optimizedUrl, isVisible, isLoaded, onLoad, onError]); + }, [optimizedUrl, isVisible, onLoad, onError]); useEffect(() => { if (isVisible && optimizedUrl && !isLoaded && !hasError) { @@ -158,26 +132,23 @@ const OptimizedImage: React.FC = ({ // Show placeholder while loading or on error if (!isLoaded || hasError) { return ( - ); } return ( - { setIsLoaded(true); onLoad?.(); diff --git a/src/components/home/ContentItem.tsx b/src/components/home/ContentItem.tsx index 347242f..4635834 100644 --- a/src/components/home/ContentItem.tsx +++ b/src/components/home/ContentItem.tsx @@ -87,6 +87,12 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe const [menuVisible, setMenuVisible] = useState(false); const [isWatched, setIsWatched] = useState(false); const [imageError, setImageError] = useState(false); + + useEffect(() => { + // Reset image error state when item changes, allowing for retry on re-render + setImageError(false); + }, [item.id, item.poster]); + const { currentTheme } = useTheme(); const { settings, isLoaded } = useSettings(); const posterRadius = typeof settings.posterBorderRadius === 'number' ? settings.posterBorderRadius : 12; @@ -245,6 +251,9 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe }} style={[styles.poster, { backgroundColor: currentTheme.colors.elevation1, borderRadius: posterRadius }]} resizeMode={FastImage.resizeMode.cover} + onLoad={() => { + setImageError(false); + }} onError={() => { if (__DEV__) console.warn('Image load error for:', item.poster); setImageError(true); @@ -359,6 +368,8 @@ const styles = StyleSheet.create({ }); export default React.memo(ContentItem, (prev, next) => { - // Only re-render when the item ID changes (FastImage handles caching internally) - return prev.item.id === next.item.id && prev.item.type === next.item.type; + // Re-render when identity or poster changes. Caching is handled by FastImage. + if (prev.item.id !== next.item.id) return false; + if (prev.item.poster !== next.item.poster) return false; + return true; }); \ No newline at end of file diff --git a/src/components/home/FeaturedContent.tsx b/src/components/home/FeaturedContent.tsx index d4dfa86..7d2f1d6 100644 --- a/src/components/home/FeaturedContent.tsx +++ b/src/components/home/FeaturedContent.tsx @@ -32,7 +32,6 @@ import { useSettings } from '../../hooks/useSettings'; import { TMDBService } from '../../services/tmdbService'; import { logger } from '../../utils/logger'; import { useTheme } from '../../contexts/ThemeContext'; -import { imageCacheService } from '../../services/imageCacheService'; interface FeaturedContentProps { featuredContent: StreamingContent | null; @@ -234,7 +233,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary, loadin }); await Promise.race([ - imageCacheService.getCachedImageUrl(url), + FastImage.preload([{ uri: url }]), timeout, ]); imageCache[url] = true; diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index 24c710e..2534421 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -61,7 +61,6 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Toast } from 'toastify-react-native'; import FirstTimeWelcome from '../components/FirstTimeWelcome'; -import { imageCacheService } from '../services/imageCacheService'; import { HeaderVisibility } from '../contexts/HeaderVisibility'; // Constants diff --git a/src/screens/LibraryScreen.tsx b/src/screens/LibraryScreen.tsx index bf619d7..731342c 100644 --- a/src/screens/LibraryScreen.tsx +++ b/src/screens/LibraryScreen.tsx @@ -94,7 +94,7 @@ const TraktItem = React.memo(({ item, width, navigation, currentTheme }: { item: let isMounted = true; const fetchPoster = async () => { if (item.images) { - const url = await TraktService.getTraktPosterUrlCached(item.images); + const url = TraktService.getTraktPosterUrl(item.images); if (isMounted && url) { setPosterUrl(url); } diff --git a/src/services/imageCacheService.ts b/src/services/imageCacheService.ts deleted file mode 100644 index b7a4c42..0000000 --- a/src/services/imageCacheService.ts +++ /dev/null @@ -1,291 +0,0 @@ -import { logger } from '../utils/logger'; -import FastImage from '@d11/react-native-fast-image'; -import { AppState, AppStateStatus } from 'react-native'; - -interface CachedImage { - url: string; - localPath: string; - timestamp: number; - expiresAt: number; - size?: number; // Track approximate memory usage - accessCount: number; // Track usage frequency - lastAccessed: number; // Track last access time -} - -class ImageCacheService { - private cache = new Map(); - private readonly CACHE_DURATION = Infinity; // Session-only: valid until app close - private readonly MAX_CACHE_SIZE = 25; // Further reduced maximum number of cached images - private readonly MAX_MEMORY_MB = 40; // Further reduced maximum memory usage in MB - private currentMemoryUsage = 0; - private cleanupInterval: NodeJS.Timeout | null = null; - private appStateSubscription: any = null; - - constructor() { - // Start cleanup interval every 15 minutes (more frequent cleanup to reduce memory pressure) - this.cleanupInterval = setInterval(() => { - this.performCleanup(); - }, 15 * 60 * 1000); - - // Reduce memory footprint when app goes to background - this.appStateSubscription = AppState.addEventListener('change', this.handleAppStateChange); - } - - /** - * Get a cached image URL or cache the original if not present - */ - public async getCachedImageUrl(originalUrl: string): Promise { - if (!originalUrl || originalUrl.includes('placeholder')) { - return originalUrl; // Don't cache placeholder images - } - - // Check if we have a valid cached version - const cached = this.cache.get(originalUrl); - if (cached && cached.expiresAt > Date.now()) { - // Update access tracking - cached.accessCount++; - cached.lastAccessed = Date.now(); - // Skip verbose logging to reduce CPU load - return cached.localPath; - } - - // Check memory pressure before adding new entries (more lenient) - if (this.cache.size >= this.MAX_CACHE_SIZE * 0.95) { - // Skip verbose logging to reduce CPU load - return originalUrl; - } - - try { - // Estimate image size (rough approximation) - const estimatedSize = this.estimateImageSize(originalUrl); - - const cachedImage: CachedImage = { - url: originalUrl, - localPath: originalUrl, // In production, this would be a local file path - timestamp: Date.now(), - expiresAt: Date.now() + this.CACHE_DURATION, - size: estimatedSize, - accessCount: 1, - lastAccessed: Date.now() - }; - - this.cache.set(originalUrl, cachedImage); - this.currentMemoryUsage += estimatedSize; - this.enforceMemoryLimits(); - - // Skip verbose logging to reduce CPU load - return cachedImage.localPath; - } catch (error) { - logger.error('[ImageCache] Failed to cache image:', error); - return originalUrl; // Fallback to original URL - } - } - - /** - * Check if an image is cached - */ - public isCached(url: string): boolean { - const cached = this.cache.get(url); - return cached !== undefined && cached.expiresAt > Date.now(); - } - - /** - * Log cache status (for debugging) - */ - public logCacheStatus(): void { - const stats = this.getCacheStats(); - logger.log(`[ImageCache] 📊 Cache Status: ${stats.size} total, ${stats.expired} expired`); - - // Log first 5 cached URLs for debugging - const entries = Array.from(this.cache.entries()).slice(0, 5); - entries.forEach(([url, cached]) => { - const isExpired = cached.expiresAt <= Date.now(); - const timeLeft = Math.max(0, cached.expiresAt - Date.now()) / 1000 / 60; // minutes - logger.log(`[ImageCache] - ${url.substring(0, 60)}... (${isExpired ? 'EXPIRED' : `${timeLeft.toFixed(1)}m left`})`); - }); - } - - /** - * Clear expired cache entries - */ - public clearExpiredCache(): void { - const now = Date.now(); - for (const [url, cached] of this.cache.entries()) { - if (cached.expiresAt <= now) { - this.cache.delete(url); - } - } - } - - /** - * Clear all cached images - */ - public clearAllCache(): void { - this.cache.clear(); - logger.log('[ImageCache] Cleared all cached images'); - } - - /** - * Get cache statistics - */ - public getCacheStats(): { size: number; expired: number } { - const now = Date.now(); - let expired = 0; - - for (const cached of this.cache.values()) { - if (cached.expiresAt <= now) { - expired++; - } - } - - return { - size: this.cache.size, - expired, - }; - } - - /** - * Enforce maximum cache size by removing oldest entries - */ - private enforceMaxCacheSize(): void { - if (this.cache.size <= this.MAX_CACHE_SIZE) { - return; - } - - // Convert to array and sort by timestamp (oldest first) - const entries = Array.from(this.cache.entries()).sort( - (a, b) => a[1].timestamp - b[1].timestamp - ); - - // Remove oldest entries - const toRemove = this.cache.size - this.MAX_CACHE_SIZE; - for (let i = 0; i < toRemove; i++) { - this.cache.delete(entries[i][0]); - } - - logger.log(`[ImageCache] Removed ${toRemove} old entries to enforce cache size limit`); - } - - /** - * Enforce memory limits using LRU eviction - */ - private enforceMemoryLimits(): void { - const maxMemoryBytes = this.MAX_MEMORY_MB * 1024 * 1024; - - if (this.currentMemoryUsage <= maxMemoryBytes) { - return; - } - - // Sort by access frequency and recency (LRU) - const entries = Array.from(this.cache.entries()).sort((a, b) => { - const scoreA = a[1].accessCount * 0.3 + (Date.now() - a[1].lastAccessed) * 0.7; - const scoreB = b[1].accessCount * 0.3 + (Date.now() - b[1].lastAccessed) * 0.7; - return scoreB - scoreA; // Higher score = more likely to be evicted - }); - - let removedCount = 0; - for (const [url, cached] of entries) { - if (this.currentMemoryUsage <= maxMemoryBytes * 0.8) { // Leave 20% buffer - break; - } - - this.cache.delete(url); - this.currentMemoryUsage -= cached.size || 0; - removedCount++; - } - - // Skip verbose memory eviction logging to reduce CPU load - } - - /** - * Estimate image size based on URL patterns - */ - private estimateImageSize(url: string): number { - // Rough estimates in bytes based on common image types - if (url.includes('poster')) return 150 * 1024; // 150KB for posters - if (url.includes('banner') || url.includes('backdrop')) return 300 * 1024; // 300KB for banners - if (url.includes('logo')) return 50 * 1024; // 50KB for logos - if (url.includes('thumb')) return 75 * 1024; // 75KB for thumbnails - return 200 * 1024; // Default 200KB - } - - /** - * Check if we should skip caching due to memory pressure - */ - private shouldSkipCaching(): boolean { - const maxMemoryBytes = this.MAX_MEMORY_MB * 1024 * 1024; - return this.currentMemoryUsage > maxMemoryBytes * 0.9 || this.cache.size >= this.MAX_CACHE_SIZE; - } - - /** - * Perform comprehensive cleanup - */ - private performCleanup(): void { - const initialSize = this.cache.size; - const initialMemory = this.currentMemoryUsage; - - // Remove expired entries - this.clearExpiredCache(); - - // Recalculate memory usage - this.recalculateMemoryUsage(); - - // Enforce limits - this.enforceMemoryLimits(); - this.enforceMaxCacheSize(); - - // Avoid clearing Expo's global memory cache to prevent re-decode churn - - const finalSize = this.cache.size; - const finalMemory = this.currentMemoryUsage; - - // Skip verbose cleanup logging to reduce CPU load - } - - /** - * Recalculate memory usage from cache entries - */ - private recalculateMemoryUsage(): void { - this.currentMemoryUsage = 0; - for (const cached of this.cache.values()) { - this.currentMemoryUsage += cached.size || 0; - } - } - - /** - * Cleanup resources - */ - public destroy(): void { - if (this.cleanupInterval) { - clearInterval(this.cleanupInterval); - this.cleanupInterval = null; - } - if (this.appStateSubscription) { - this.appStateSubscription.remove(); - this.appStateSubscription = null; - } - this.clearAllCache(); - } - - private handleAppStateChange = (nextState: AppStateStatus) => { - if (nextState !== 'active') { - // On background/inactive, aggressively trim cache to 25% to reduce memory pressure - const targetSize = Math.floor(this.MAX_CACHE_SIZE * 0.25); - if (this.cache.size > targetSize) { - const entries = Array.from(this.cache.entries()); - const toRemove = this.cache.size - targetSize; - for (let i = 0; i < toRemove; i++) { - const [url, cached] = entries[i]; - this.cache.delete(url); - this.currentMemoryUsage -= cached.size || 0; - } - } - // Force aggressive memory cleanup - this.enforceMemoryLimits(); - // Clear any remaining memory pressure - this.currentMemoryUsage = Math.min(this.currentMemoryUsage, this.MAX_MEMORY_MB * 1024 * 1024 * 0.3); - } - }; -} - -export const imageCacheService = new ImageCacheService(); \ No newline at end of file diff --git a/src/services/traktService.ts b/src/services/traktService.ts index d3bf759..748de05 100644 --- a/src/services/traktService.ts +++ b/src/services/traktService.ts @@ -1176,7 +1176,7 @@ export class TraktService { } /** - * Extract poster URL from Trakt images with basic caching + * Extract poster URL from Trakt images */ public static getTraktPosterUrl(images?: TraktImages): string | null { if (!images || !images.poster || images.poster.length === 0) { @@ -1185,36 +1185,7 @@ export class TraktService { // Get the first poster and add https prefix const posterPath = images.poster[0]; - const fullUrl = posterPath.startsWith('http') ? posterPath : `https://${posterPath}`; - - // Try to use cached version synchronously (basic cache check) - const isCached = imageCacheService.isCached(fullUrl); - if (isCached) { - logger.log(`[TraktService] 🎯 Using cached poster: ${fullUrl.substring(0, 60)}...`); - } else { - logger.log(`[TraktService] 📥 New poster URL: ${fullUrl.substring(0, 60)}...`); - // Queue for async caching - imageCacheService.getCachedImageUrl(fullUrl).catch(error => { - logger.error('[TraktService] Background caching failed:', error); - }); - } - - return fullUrl; - } - - /** - * Extract poster URL from Trakt images with async caching - */ - public static async getTraktPosterUrlCached(images?: TraktImages): Promise { - const url = this.getTraktPosterUrl(images); - if (!url) return null; - - try { - return await imageCacheService.getCachedImageUrl(url); - } catch (error) { - logger.error('[TraktService] Failed to cache image:', error); - return url; - } + return posterPath.startsWith('http') ? posterPath : `https://${posterPath}`; } /** @@ -2029,17 +2000,6 @@ export class TraktService { logger.error('[TraktService] DEBUG: Error fetching playback progress:', error); } } - /** - * Debug image cache status - */ - public static debugImageCache(): void { - try { - logger.log('[TraktService] === IMAGE CACHE DEBUG ==='); - imageCacheService.logCacheStatus(); - } catch (error) { - logger.error('[TraktService] Debug image cache failed:', error); - } - } /** * Delete a playback progress entry on Trakt by its playback `id`.