From 14dd507d5025d342413ca736f48231a815b44bdf Mon Sep 17 00:00:00 2001 From: tapframe Date: Sat, 21 Jun 2025 23:48:38 +0530 Subject: [PATCH] Refactor image handling and caching strategies in multiple components for enhanced performance This update modifies the image handling in ContentItem, ContinueWatchingSection, and FeaturedContent components to utilize a more efficient memory caching strategy and adjusted transition durations. Additionally, the HomeScreen component has been optimized for image prefetching, limiting concurrent requests to reduce memory pressure. The ThisWeekSection has been simplified to always refresh episodes when library items change, improving data handling. These changes aim to create a smoother user experience while navigating through content. --- src/components/home/ContentItem.tsx | 5 +- .../home/ContinueWatchingSection.tsx | 5 +- src/components/home/FeaturedContent.tsx | 195 ++++++++-------- src/components/home/ThisWeekSection.tsx | 11 +- src/hooks/useFeaturedContent.ts | 41 +--- src/screens/HomeScreen.tsx | 55 ++--- src/services/imageCacheService.ts | 211 +++--------------- src/utils/catalogNameUtils.ts | 12 +- 8 files changed, 179 insertions(+), 356 deletions(-) diff --git a/src/components/home/ContentItem.tsx b/src/components/home/ContentItem.tsx index e84718b73..b222903b7 100644 --- a/src/components/home/ContentItem.tsx +++ b/src/components/home/ContentItem.tsx @@ -102,12 +102,11 @@ const ContentItem = React.memo(({ item, onPress }: ContentItemProps) => { source={{ uri: item.poster || 'https://via.placeholder.com/300x450' }} style={styles.poster} contentFit="cover" - cachePolicy="memory-disk" - transition={150} + cachePolicy="memory" + transition={200} placeholder={{ uri: 'https://via.placeholder.com/300x450' }} placeholderContentFit="cover" recyclingKey={item.id} - priority="high" onLoadStart={() => { setImageLoaded(false); setImageError(false); diff --git a/src/components/home/ContinueWatchingSection.tsx b/src/components/home/ContinueWatchingSection.tsx index d10d02fe4..1035d8ad8 100644 --- a/src/components/home/ContinueWatchingSection.tsx +++ b/src/components/home/ContinueWatchingSection.tsx @@ -309,12 +309,11 @@ const ContinueWatchingSection = React.forwardRef((props, re source={{ uri: item.poster || 'https://via.placeholder.com/300x450' }} style={styles.continueWatchingPoster} contentFit="cover" - cachePolicy="memory-disk" - transition={150} + cachePolicy="memory" + transition={200} placeholder={{ uri: 'https://via.placeholder.com/300x450' }} placeholderContentFit="cover" recyclingKey={item.id} - priority="high" /> diff --git a/src/components/home/FeaturedContent.tsx b/src/components/home/FeaturedContent.tsx index 76c7d5801..64ebe7bca 100644 --- a/src/components/home/FeaturedContent.tsx +++ b/src/components/home/FeaturedContent.tsx @@ -426,111 +426,106 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat style={styles.featuredContainer as ViewStyle} > - - {/* Subtle content overlay for better readability */} - - - - + + - {logoUrl && !logoLoadError ? ( - - + {logoUrl && !logoLoadError ? ( + + + + ) : ( + + {featuredContent.name} + + )} + + {featuredContent.genres?.slice(0, 3).map((genre, index, array) => ( + + + {genre} + + {index < array.length - 1 && ( + + )} + + ))} + + + + + + - - ) : ( - - {featuredContent.name} - - )} - - {featuredContent.genres?.slice(0, 3).map((genre, index, array) => ( - - - {genre} - - {index < array.length - 1 && ( - - )} - - ))} - - + + {isSaved ? "Saved" : "Save"} + + + + { + if (featuredContent) { + navigation.navigate('Streams', { + id: featuredContent.id, + type: featuredContent.type + }); + } + }} + activeOpacity={0.8} + > + + + Play + + - - - - - {isSaved ? "Saved" : "Save"} - - - - { - if (featuredContent) { - navigation.navigate('Streams', { - id: featuredContent.id, - type: featuredContent.type - }); - } - }} - activeOpacity={0.8} - > - - - Play - - - - - - - Info - - - - + + + + Info + + + + + diff --git a/src/components/home/ThisWeekSection.tsx b/src/components/home/ThisWeekSection.tsx index 412427174..fbba9d729 100644 --- a/src/components/home/ThisWeekSection.tsx +++ b/src/components/home/ThisWeekSection.tsx @@ -130,15 +130,10 @@ export const ThisWeekSection = () => { // Load episodes when library items change useEffect(() => { if (!libraryLoading) { - // Only fetch if we have library items or if this is the first load - if (libraryItems.length > 0 || episodes.length === 0) { - console.log('[ThisWeekSection] Library items changed, refreshing episodes. Items count:', libraryItems.length); - fetchThisWeekEpisodes(); - } else { - console.log('[ThisWeekSection] Skipping refresh - no library items and episodes already loaded'); - } + console.log('[ThisWeekSection] Library items changed, refreshing episodes. Items count:', libraryItems.length); + fetchThisWeekEpisodes(); } - }, [libraryLoading, libraryItems, fetchThisWeekEpisodes, episodes.length]); + }, [libraryLoading, libraryItems, fetchThisWeekEpisodes]); const handleEpisodePress = (episode: ThisWeekEpisode) => { // For upcoming episodes, go to the metadata screen diff --git a/src/hooks/useFeaturedContent.ts b/src/hooks/useFeaturedContent.ts index 01415ddee..249fefef5 100644 --- a/src/hooks/useFeaturedContent.ts +++ b/src/hooks/useFeaturedContent.ts @@ -242,37 +242,16 @@ export function useFeaturedContent() { // Load featured content initially and when content source changes useEffect(() => { - if (!settings.showHeroSection) { - setFeaturedContent(null); + // Force refresh when switching to catalogs or when catalog selection changes + if (contentSource === 'catalogs') { + // Clear cache when switching to catalogs mode setAllFeaturedContent([]); - setLoading(false); - return; - } - - // Only load if we don't have cached data or if settings actually changed - const now = Date.now(); - const cacheAge = now - persistentStore.lastFetchTime; - const hasValidCache = persistentStore.featuredContent && - persistentStore.allFeaturedContent.length > 0 && - cacheAge < CACHE_TIMEOUT; - - // Check if this is truly a settings change or just a re-render - const sourceChanged = persistentStore.lastSettings.featuredContentSource !== contentSource; - const catalogsChanged = JSON.stringify(persistentStore.lastSettings.selectedHeroCatalogs) !== JSON.stringify(selectedCatalogs); - - if (hasValidCache && !sourceChanged && !catalogsChanged) { - // Use existing cache without reloading - console.log('Using existing cached featured content, no reload needed'); - setFeaturedContent(persistentStore.featuredContent); - setAllFeaturedContent(persistentStore.allFeaturedContent); - setLoading(false); - return; - } - - // Force refresh when switching modes or when selection changes - if (sourceChanged || catalogsChanged) { - console.log('Settings changed, refreshing featured content'); - // Clear cache when switching modes + setFeaturedContent(null); + persistentStore.allFeaturedContent = []; + persistentStore.featuredContent = null; + loadFeaturedContent(true); + } else if (contentSource === 'tmdb' && contentSource !== persistentStore.featuredContent?.type) { + // Clear cache when switching to TMDB mode from catalogs setAllFeaturedContent([]); setFeaturedContent(null); persistentStore.allFeaturedContent = []; @@ -282,7 +261,7 @@ export function useFeaturedContent() { // Normal load (might use cache if available) loadFeaturedContent(false); } - }, [loadFeaturedContent, contentSource, selectedCatalogs, settings.showHeroSection]); + }, [loadFeaturedContent, contentSource, selectedCatalogs]); useEffect(() => { if (featuredContent) { diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index 88e5ae71b..d853ff1a5 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -325,34 +325,35 @@ const HomeScreen = () => { if (!content.length) return; try { - // More conservative prefetching to prevent memory issues - const BATCH_SIZE = 2; + // Limit concurrent prefetching to prevent memory pressure + const MAX_CONCURRENT_PREFETCH = 5; + const BATCH_SIZE = 3; - // Only prefetch poster images (most important) and limit to 6 items - const posterImages = content.slice(0, 6) - .map(item => item.poster) + const allImages = content.slice(0, 10) // Limit total images to prefetch + .map(item => [item.poster, item.banner, item.logo]) + .flat() .filter(Boolean) as string[]; - // Process in small batches with longer delays - for (let i = 0; i < posterImages.length; i += BATCH_SIZE) { - const batch = posterImages.slice(i, i + BATCH_SIZE); + // Process in small batches to prevent memory pressure + for (let i = 0; i < allImages.length; i += BATCH_SIZE) { + const batch = allImages.slice(i, i + BATCH_SIZE); try { await Promise.all( batch.map(async (imageUrl) => { try { await ExpoImage.prefetch(imageUrl); - // Longer delay between prefetches - await new Promise(resolve => setTimeout(resolve, 50)); + // Small delay between prefetches to reduce memory pressure + await new Promise(resolve => setTimeout(resolve, 10)); } catch (error) { // Silently handle individual prefetch errors } }) ); - // Longer delay between batches to allow GC - if (i + BATCH_SIZE < posterImages.length) { - await new Promise(resolve => setTimeout(resolve, 200)); + // Delay between batches to allow GC + if (i + BATCH_SIZE < allImages.length) { + await new Promise(resolve => setTimeout(resolve, 50)); } } catch (error) { // Continue with next batch if current batch fails @@ -371,18 +372,26 @@ const HomeScreen = () => { if (!featuredContent) return; try { + // Clear image cache to reduce memory pressure before orientation change + if (typeof (global as any)?.ExpoImage?.clearMemoryCache === 'function') { + try { + (global as any).ExpoImage.clearMemoryCache(); + } catch (e) { + // Ignore cache clear errors + } + } + // Lock orientation to landscape before navigation to prevent glitches - // Don't clear image cache - let ExpoImage manage memory efficiently try { - await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE); - + await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE); + // Longer delay to ensure orientation is fully set before navigation await new Promise(resolve => setTimeout(resolve, 200)); } catch (orientationError) { // If orientation lock fails, continue anyway but log it logger.warn('[HomeScreen] Orientation lock failed:', orientationError); // Still add a small delay - await new Promise(resolve => setTimeout(resolve, 100)); + await new Promise(resolve => setTimeout(resolve, 100)); } navigation.navigate('Player', { @@ -425,23 +434,17 @@ const HomeScreen = () => { useEffect(() => { const unsubscribe = navigation.addListener('focus', () => { - // Only refresh continue watching section if it's empty (first load or error state) - if (continueWatchingRef.current) { - // Check if continue watching is empty before refreshing - // This prevents unnecessary reloads when just switching tabs - console.log('[HomeScreen] Screen focused - checking if continue watching needs refresh'); - } - + // Only refresh continue watching section on focus + refreshContinueWatching(); // Don't reload catalogs unless they haven't been loaded yet // Catalogs will be refreshed through context updates when addons change if (catalogs.length === 0 && !catalogsLoading) { - console.log('[HomeScreen] Loading catalogs for first time'); loadCatalogsProgressively(); } }); return unsubscribe; - }, [navigation, loadCatalogsProgressively, catalogs.length, catalogsLoading]); + }, [navigation, refreshContinueWatching, loadCatalogsProgressively, catalogs.length, catalogsLoading]); // Memoize the loading screen to prevent unnecessary re-renders const renderLoadingScreen = useMemo(() => { diff --git a/src/services/imageCacheService.ts b/src/services/imageCacheService.ts index fa3ff7a43..e5a8bcacc 100644 --- a/src/services/imageCacheService.ts +++ b/src/services/imageCacheService.ts @@ -1,5 +1,4 @@ import { logger } from '../utils/logger'; -import AsyncStorage from '@react-native-async-storage/async-storage'; interface CachedImage { url: string; @@ -8,55 +7,10 @@ interface CachedImage { expiresAt: number; } -interface PersistentCacheEntry { - url: string; - timestamp: number; - expiresAt: number; - accessCount: number; - lastAccessed: number; -} - class ImageCacheService { private cache = new Map(); - private persistentCache = new Map(); - private readonly CACHE_DURATION = 7 * 24 * 60 * 60 * 1000; // 7 days (longer cache) - private readonly MAX_CACHE_SIZE = 200; // Increased cache size for better performance - private readonly PERSISTENT_CACHE_KEY = 'image_cache_persistent'; - private isInitialized = false; - - /** - * Initialize the persistent cache from storage - */ - public async initialize(): Promise { - if (this.isInitialized) return; - - try { - const stored = await AsyncStorage.getItem(this.PERSISTENT_CACHE_KEY); - if (stored) { - const entries = JSON.parse(stored) as PersistentCacheEntry[]; - entries.forEach(entry => { - this.persistentCache.set(entry.url, entry); - }); - logger.log(`[ImageCache] Loaded ${entries.length} persistent cache entries`); - } - this.isInitialized = true; - } catch (error) { - logger.error('[ImageCache] Failed to load persistent cache:', error); - this.isInitialized = true; - } - } - - /** - * Save persistent cache to storage - */ - private async savePersistentCache(): Promise { - try { - const entries = Array.from(this.persistentCache.values()); - await AsyncStorage.setItem(this.PERSISTENT_CACHE_KEY, JSON.stringify(entries)); - } catch (error) { - logger.error('[ImageCache] Failed to save persistent cache:', error); - } - } + private readonly CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 hours + private readonly MAX_CACHE_SIZE = 100; // Maximum number of cached images /** * Get a cached image URL or cache the original if not present @@ -66,66 +20,27 @@ class ImageCacheService { return originalUrl; // Don't cache placeholder images } - await this.initialize(); - - // Check memory cache first (fastest) + // Check if we have a valid cached version const cached = this.cache.get(originalUrl); if (cached && cached.expiresAt > Date.now()) { - logger.log(`[ImageCache] Retrieved from memory cache: ${originalUrl}`); + logger.log(`[ImageCache] Retrieved from cache: ${originalUrl}`); return cached.localPath; } - // Check persistent cache - const persistent = this.persistentCache.get(originalUrl); - if (persistent && persistent.expiresAt > Date.now()) { - // Update access stats - persistent.accessCount++; - persistent.lastAccessed = Date.now(); - - // Add to memory cache for faster access - const cachedImage: CachedImage = { - url: originalUrl, - localPath: originalUrl, - timestamp: persistent.timestamp, - expiresAt: persistent.expiresAt, - }; - this.cache.set(originalUrl, cachedImage); - - logger.log(`[ImageCache] Retrieved from persistent cache: ${originalUrl}`); - return originalUrl; - } - try { - // Create new cache entry - const now = Date.now(); - const expiresAt = now + this.CACHE_DURATION; - + // For now, return the original URL but mark it as cached + // In a production app, you would implement actual local caching here const cachedImage: CachedImage = { url: originalUrl, - localPath: originalUrl, - timestamp: now, - expiresAt, - }; - - const persistentEntry: PersistentCacheEntry = { - url: originalUrl, - timestamp: now, - expiresAt, - accessCount: 1, - lastAccessed: now, + localPath: originalUrl, // In production, this would be a local file path + timestamp: Date.now(), + expiresAt: Date.now() + this.CACHE_DURATION, }; this.cache.set(originalUrl, cachedImage); - this.persistentCache.set(originalUrl, persistentEntry); - this.enforceMaxCacheSize(); - - // Save persistent cache periodically (every 10 new entries) - if (this.persistentCache.size % 10 === 0) { - this.savePersistentCache(); - } - logger.log(`[ImageCache] ✅ NEW CACHE ENTRY: ${originalUrl} (Memory: ${this.cache.size}, Persistent: ${this.persistentCache.size})`); + logger.log(`[ImageCache] ✅ NEW CACHE ENTRY: ${originalUrl} (Cache size: ${this.cache.size})`); return cachedImage.localPath; } catch (error) { logger.error('[ImageCache] Failed to cache image:', error); @@ -136,82 +51,44 @@ class ImageCacheService { /** * Check if an image is cached */ - public async isCached(url: string): Promise { - await this.initialize(); - + public isCached(url: string): boolean { const cached = this.cache.get(url); - if (cached && cached.expiresAt > Date.now()) { - return true; - } - - const persistent = this.persistentCache.get(url); - return persistent !== undefined && persistent.expiresAt > Date.now(); + return cached !== undefined && cached.expiresAt > Date.now(); } /** * Log cache status (for debugging) */ - public async logCacheStatus(): Promise { - await this.initialize(); + public logCacheStatus(): void { + const stats = this.getCacheStats(); + logger.log(`[ImageCache] 📊 Cache Status: ${stats.size} total, ${stats.expired} expired`); - const memoryStats = this.getCacheStats(); - const persistentCount = this.persistentCache.size; - const persistentExpired = Array.from(this.persistentCache.values()) - .filter(entry => entry.expiresAt <= Date.now()).length; - - logger.log(`[ImageCache] 📊 Memory Cache: ${memoryStats.size} total, ${memoryStats.expired} expired`); - logger.log(`[ImageCache] 📊 Persistent Cache: ${persistentCount} total, ${persistentExpired} expired`); - - // Log most accessed images - const topImages = Array.from(this.persistentCache.entries()) - .sort(([, a], [, b]) => b.accessCount - a.accessCount) - .slice(0, 5); - - topImages.forEach(([url, entry]) => { - logger.log(`[ImageCache] 🔥 Popular: ${url.substring(0, 60)}... (${entry.accessCount} accesses)`); + // 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 async clearExpiredCache(): Promise { - await this.initialize(); - + public clearExpiredCache(): void { const now = Date.now(); - let removedMemory = 0; - let removedPersistent = 0; - - // Clear memory cache for (const [url, cached] of this.cache.entries()) { if (cached.expiresAt <= now) { this.cache.delete(url); - removedMemory++; } } - - // Clear persistent cache - for (const [url, entry] of this.persistentCache.entries()) { - if (entry.expiresAt <= now) { - this.persistentCache.delete(url); - removedPersistent++; - } - } - - if (removedPersistent > 0) { - await this.savePersistentCache(); - } - - logger.log(`[ImageCache] Cleared ${removedMemory} memory entries, ${removedPersistent} persistent entries`); } /** * Clear all cached images */ - public async clearAllCache(): Promise { + public clearAllCache(): void { this.cache.clear(); - this.persistentCache.clear(); - await AsyncStorage.removeItem(this.PERSISTENT_CACHE_KEY); logger.log('[ImageCache] Cleared all cached images'); } @@ -235,40 +112,26 @@ class ImageCacheService { } /** - * Enforce maximum cache size by removing oldest/least accessed entries + * Enforce maximum cache size by removing oldest entries */ private enforceMaxCacheSize(): void { - // Enforce memory cache limit - if (this.cache.size > this.MAX_CACHE_SIZE) { - const entries = Array.from(this.cache.entries()).sort( - (a, b) => a[1].timestamp - b[1].timestamp - ); - - 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 memory entries to enforce cache size limit`); + if (this.cache.size <= this.MAX_CACHE_SIZE) { + return; } - - // Enforce persistent cache limit (larger limit) - const persistentLimit = this.MAX_CACHE_SIZE * 3; - if (this.persistentCache.size > persistentLimit) { - // Remove least recently accessed entries - const entries = Array.from(this.persistentCache.entries()).sort( - (a, b) => a[1].lastAccessed - b[1].lastAccessed - ); - const toRemove = this.persistentCache.size - persistentLimit; - for (let i = 0; i < toRemove; i++) { - this.persistentCache.delete(entries[i][0]); - } + // 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 + ); - this.savePersistentCache(); - logger.log(`[ImageCache] Removed ${toRemove} old persistent entries to enforce cache size limit`); + // 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`); } } -export const imageCacheService = new ImageCacheService(); +export const imageCacheService = new ImageCacheService(); \ No newline at end of file diff --git a/src/utils/catalogNameUtils.ts b/src/utils/catalogNameUtils.ts index 2fa0bfbec..195cb3890 100644 --- a/src/utils/catalogNameUtils.ts +++ b/src/utils/catalogNameUtils.ts @@ -6,8 +6,7 @@ const CATALOG_CUSTOM_NAMES_KEY = 'catalog_custom_names'; // Initialize cache as an empty object let customNamesCache: { [key: string]: string } = {}; let cacheTimestamp: number = 0; // 0 indicates cache is invalid/empty initially -let isLoading: boolean = false; // Prevent multiple concurrent loads -const CACHE_DURATION = 30 * 60 * 1000; // 30 minutes (longer cache for less frequent loads) +const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes async function loadCustomNamesIfNeeded(): Promise<{ [key: string]: string }> { const now = Date.now(); @@ -16,13 +15,6 @@ async function loadCustomNamesIfNeeded(): Promise<{ [key: string]: string }> { return customNamesCache; // Cache is valid and guaranteed to be an object } - // Prevent multiple concurrent loads - if (isLoading) { - // Wait for current load to complete by returning existing cache or empty object - return customNamesCache || {}; - } - - isLoading = true; try { logger.info('Loading custom catalog names from storage...'); const savedCustomNamesJson = await AsyncStorage.getItem(CATALOG_CUSTOM_NAMES_KEY); @@ -36,8 +28,6 @@ async function loadCustomNamesIfNeeded(): Promise<{ [key: string]: string }> { cacheTimestamp = 0; // Return the last known cache (which might be empty {}), or a fresh empty object return customNamesCache || {}; // Return cache (could be outdated but non-null) or empty {} - } finally { - isLoading = false; } }