diff --git a/src/components/home/ContentItem.tsx b/src/components/home/ContentItem.tsx index b222903b..e84718b7 100644 --- a/src/components/home/ContentItem.tsx +++ b/src/components/home/ContentItem.tsx @@ -102,11 +102,12 @@ const ContentItem = React.memo(({ item, onPress }: ContentItemProps) => { source={{ uri: item.poster || 'https://via.placeholder.com/300x450' }} style={styles.poster} contentFit="cover" - cachePolicy="memory" - transition={200} + cachePolicy="memory-disk" + transition={150} 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 1035d8ad..d10d02fe 100644 --- a/src/components/home/ContinueWatchingSection.tsx +++ b/src/components/home/ContinueWatchingSection.tsx @@ -309,11 +309,12 @@ const ContinueWatchingSection = React.forwardRef((props, re source={{ uri: item.poster || 'https://via.placeholder.com/300x450' }} style={styles.continueWatchingPoster} contentFit="cover" - cachePolicy="memory" - transition={200} + cachePolicy="memory-disk" + transition={150} 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 64ebe7bc..76c7d580 100644 --- a/src/components/home/FeaturedContent.tsx +++ b/src/components/home/FeaturedContent.tsx @@ -426,106 +426,111 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat style={styles.featuredContainer as ViewStyle} > - + {/* Subtle content overlay for better readability */} + + + - {/* Subtle content overlay for better readability */} - - - - - {logoUrl && !logoLoadError ? ( - - - - ) : ( - - {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 - - + + ) : ( + + {featuredContent.name} + + )} + + {featuredContent.genres?.slice(0, 3).map((genre, index, array) => ( + + + {genre} + + {index < array.length - 1 && ( + + )} + + ))} + + - - - - Info - - - - - + + + + + {isSaved ? "Saved" : "Save"} + + + + { + if (featuredContent) { + navigation.navigate('Streams', { + id: featuredContent.id, + type: featuredContent.type + }); + } + }} + activeOpacity={0.8} + > + + + Play + + + + + + + Info + + + + diff --git a/src/components/home/ThisWeekSection.tsx b/src/components/home/ThisWeekSection.tsx index fbba9d72..41242717 100644 --- a/src/components/home/ThisWeekSection.tsx +++ b/src/components/home/ThisWeekSection.tsx @@ -130,10 +130,15 @@ export const ThisWeekSection = () => { // Load episodes when library items change useEffect(() => { if (!libraryLoading) { - console.log('[ThisWeekSection] Library items changed, refreshing episodes. Items count:', libraryItems.length); - fetchThisWeekEpisodes(); + // 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'); + } } - }, [libraryLoading, libraryItems, fetchThisWeekEpisodes]); + }, [libraryLoading, libraryItems, fetchThisWeekEpisodes, episodes.length]); const handleEpisodePress = (episode: ThisWeekEpisode) => { // For upcoming episodes, go to the metadata screen diff --git a/src/components/metadata/SeriesContent.tsx b/src/components/metadata/SeriesContent.tsx index b3978029..6a9e7310 100644 --- a/src/components/metadata/SeriesContent.tsx +++ b/src/components/metadata/SeriesContent.tsx @@ -71,7 +71,7 @@ export const SeriesContent: React.FC = ({ // Function to find and scroll to the most recently watched episode const scrollToMostRecentEpisode = () => { - if (!metadata?.id || !episodeScrollViewRef.current || settings.episodeLayoutStyle !== 'horizontal') { + if (!metadata?.id || !episodeScrollViewRef.current || !settings?.episodeLayoutStyle || settings.episodeLayoutStyle !== 'horizontal') { return; } @@ -147,10 +147,10 @@ export const SeriesContent: React.FC = ({ // Add effect to scroll to most recently watched episode when season changes or progress loads useEffect(() => { - if (Object.keys(episodeProgress).length > 0 && selectedSeason) { + if (Object.keys(episodeProgress).length > 0 && selectedSeason && settings?.episodeLayoutStyle) { scrollToMostRecentEpisode(); } - }, [selectedSeason, episodeProgress, settings.episodeLayoutStyle, groupedEpisodes]); + }, [selectedSeason, episodeProgress, settings?.episodeLayoutStyle, groupedEpisodes]); @@ -561,7 +561,7 @@ export const SeriesContent: React.FC = ({ {/* Only render episode list if there are episodes */} {currentSeasonEpisodes.length > 0 && ( - settings.episodeLayoutStyle === 'horizontal' ? ( + (settings?.episodeLayoutStyle === 'horizontal') ? ( // Horizontal Layout (Netflix-style) >} diff --git a/src/hooks/useFeaturedContent.ts b/src/hooks/useFeaturedContent.ts index 249fefef..01415dde 100644 --- a/src/hooks/useFeaturedContent.ts +++ b/src/hooks/useFeaturedContent.ts @@ -242,16 +242,37 @@ export function useFeaturedContent() { // Load featured content initially and when content source changes useEffect(() => { - // Force refresh when switching to catalogs or when catalog selection changes - if (contentSource === 'catalogs') { - // Clear cache when switching to catalogs mode - setAllFeaturedContent([]); + if (!settings.showHeroSection) { 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([]); + 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 setAllFeaturedContent([]); setFeaturedContent(null); persistentStore.allFeaturedContent = []; @@ -261,7 +282,7 @@ export function useFeaturedContent() { // Normal load (might use cache if available) loadFeaturedContent(false); } - }, [loadFeaturedContent, contentSource, selectedCatalogs]); + }, [loadFeaturedContent, contentSource, selectedCatalogs, settings.showHeroSection]); useEffect(() => { if (featuredContent) { diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index ec3b664b..5f473285 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -78,10 +78,14 @@ export const useSettings = () => { try { const storedSettings = await AsyncStorage.getItem(SETTINGS_STORAGE_KEY); if (storedSettings) { - setSettings(JSON.parse(storedSettings)); + const parsedSettings = JSON.parse(storedSettings); + // Merge with defaults to ensure all properties exist + setSettings({ ...DEFAULT_SETTINGS, ...parsedSettings }); } } catch (error) { console.error('Failed to load settings:', error); + // Fallback to default settings on error + setSettings(DEFAULT_SETTINGS); } }; diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index d853ff1a..88e5ae71 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -325,35 +325,34 @@ const HomeScreen = () => { if (!content.length) return; try { - // Limit concurrent prefetching to prevent memory pressure - const MAX_CONCURRENT_PREFETCH = 5; - const BATCH_SIZE = 3; + // More conservative prefetching to prevent memory issues + const BATCH_SIZE = 2; - const allImages = content.slice(0, 10) // Limit total images to prefetch - .map(item => [item.poster, item.banner, item.logo]) - .flat() + // Only prefetch poster images (most important) and limit to 6 items + const posterImages = content.slice(0, 6) + .map(item => item.poster) .filter(Boolean) as string[]; - // 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); + // 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); try { await Promise.all( batch.map(async (imageUrl) => { try { await ExpoImage.prefetch(imageUrl); - // Small delay between prefetches to reduce memory pressure - await new Promise(resolve => setTimeout(resolve, 10)); + // Longer delay between prefetches + await new Promise(resolve => setTimeout(resolve, 50)); } catch (error) { // Silently handle individual prefetch errors } }) ); - // Delay between batches to allow GC - if (i + BATCH_SIZE < allImages.length) { - await new Promise(resolve => setTimeout(resolve, 50)); + // Longer delay between batches to allow GC + if (i + BATCH_SIZE < posterImages.length) { + await new Promise(resolve => setTimeout(resolve, 200)); } } catch (error) { // Continue with next batch if current batch fails @@ -372,26 +371,18 @@ 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', { @@ -434,17 +425,23 @@ const HomeScreen = () => { useEffect(() => { const unsubscribe = navigation.addListener('focus', () => { - // Only refresh continue watching section on focus - refreshContinueWatching(); + // 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'); + } + // 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, refreshContinueWatching, loadCatalogsProgressively, catalogs.length, catalogsLoading]); + }, [navigation, loadCatalogsProgressively, catalogs.length, catalogsLoading]); // Memoize the loading screen to prevent unnecessary re-renders const renderLoadingScreen = useMemo(() => { diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index 6a11bcbb..5ef3d627 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -97,22 +97,22 @@ const SettingItem: React.FC = ({ styles.settingIconContainer, { backgroundColor: 'rgba(255,255,255,0.1)' } ]}> - + - + {title} {description && ( - + {description} )} {badge && ( - {badge} + {String(badge)} )} @@ -395,14 +395,14 @@ const SettingsScreen: React.FC = () => { /> ( { { { { (); - private readonly CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 hours - private readonly MAX_CACHE_SIZE = 100; // Maximum number of cached images + 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); + } + } /** * Get a cached image URL or cache the original if not present @@ -20,27 +66,66 @@ class ImageCacheService { return originalUrl; // Don't cache placeholder images } - // Check if we have a valid cached version + await this.initialize(); + + // Check memory cache first (fastest) const cached = this.cache.get(originalUrl); if (cached && cached.expiresAt > Date.now()) { - logger.log(`[ImageCache] Retrieved from cache: ${originalUrl}`); + logger.log(`[ImageCache] Retrieved from memory cache: ${originalUrl}`); return cached.localPath; } - try { - // For now, return the original URL but mark it as cached - // In a production app, you would implement actual local caching here + // 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, // In production, this would be a local file path - timestamp: Date.now(), - expiresAt: Date.now() + this.CACHE_DURATION, + 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; + + const cachedImage: CachedImage = { + url: originalUrl, + localPath: originalUrl, + timestamp: now, + expiresAt, + }; + + const persistentEntry: PersistentCacheEntry = { + url: originalUrl, + timestamp: now, + expiresAt, + accessCount: 1, + lastAccessed: now, }; 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} (Cache size: ${this.cache.size})`); + logger.log(`[ImageCache] ✅ NEW CACHE ENTRY: ${originalUrl} (Memory: ${this.cache.size}, Persistent: ${this.persistentCache.size})`); return cachedImage.localPath; } catch (error) { logger.error('[ImageCache] Failed to cache image:', error); @@ -51,44 +136,82 @@ class ImageCacheService { /** * Check if an image is cached */ - public isCached(url: string): boolean { + public async isCached(url: string): Promise { + await this.initialize(); + const cached = this.cache.get(url); - return cached !== undefined && cached.expiresAt > Date.now(); + if (cached && cached.expiresAt > Date.now()) { + return true; + } + + const persistent = this.persistentCache.get(url); + return persistent !== undefined && persistent.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`); + public async logCacheStatus(): Promise { + await this.initialize(); - // 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`})`); + 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)`); }); } /** * Clear expired cache entries */ - public clearExpiredCache(): void { + public async clearExpiredCache(): Promise { + await this.initialize(); + 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 clearAllCache(): void { + public async clearAllCache(): Promise { this.cache.clear(); + this.persistentCache.clear(); + await AsyncStorage.removeItem(this.PERSISTENT_CACHE_KEY); logger.log('[ImageCache] Cleared all cached images'); } @@ -112,26 +235,40 @@ class ImageCacheService { } /** - * Enforce maximum cache size by removing oldest entries + * Enforce maximum cache size by removing oldest/least accessed entries */ private enforceMaxCacheSize(): void { - if (this.cache.size <= this.MAX_CACHE_SIZE) { - return; + // 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`); } + + // 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 + ); - // 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 - ); + const toRemove = this.persistentCache.size - persistentLimit; + for (let i = 0; i < toRemove; i++) { + this.persistentCache.delete(entries[i][0]); + } - // 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]); + this.savePersistentCache(); + logger.log(`[ImageCache] Removed ${toRemove} old persistent entries to enforce cache size limit`); } - - logger.log(`[ImageCache] Removed ${toRemove} old entries to enforce cache size limit`); } } -export const imageCacheService = new ImageCacheService(); \ No newline at end of file +export const imageCacheService = new ImageCacheService(); diff --git a/src/utils/catalogNameUtils.ts b/src/utils/catalogNameUtils.ts index 195cb389..2fa0bfbe 100644 --- a/src/utils/catalogNameUtils.ts +++ b/src/utils/catalogNameUtils.ts @@ -6,7 +6,8 @@ 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 -const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes +let isLoading: boolean = false; // Prevent multiple concurrent loads +const CACHE_DURATION = 30 * 60 * 1000; // 30 minutes (longer cache for less frequent loads) async function loadCustomNamesIfNeeded(): Promise<{ [key: string]: string }> { const now = Date.now(); @@ -15,6 +16,13 @@ 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); @@ -28,6 +36,8 @@ 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; } }