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; } }