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.
This commit is contained in:
tapframe 2025-06-21 23:48:38 +05:30
parent 15767886b3
commit 14dd507d50
8 changed files with 179 additions and 356 deletions

View file

@ -102,12 +102,11 @@ const ContentItem = React.memo(({ item, onPress }: ContentItemProps) => {
source={{ uri: item.poster || 'https://via.placeholder.com/300x450' }} source={{ uri: item.poster || 'https://via.placeholder.com/300x450' }}
style={styles.poster} style={styles.poster}
contentFit="cover" contentFit="cover"
cachePolicy="memory-disk" cachePolicy="memory"
transition={150} transition={200}
placeholder={{ uri: 'https://via.placeholder.com/300x450' }} placeholder={{ uri: 'https://via.placeholder.com/300x450' }}
placeholderContentFit="cover" placeholderContentFit="cover"
recyclingKey={item.id} recyclingKey={item.id}
priority="high"
onLoadStart={() => { onLoadStart={() => {
setImageLoaded(false); setImageLoaded(false);
setImageError(false); setImageError(false);

View file

@ -309,12 +309,11 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
source={{ uri: item.poster || 'https://via.placeholder.com/300x450' }} source={{ uri: item.poster || 'https://via.placeholder.com/300x450' }}
style={styles.continueWatchingPoster} style={styles.continueWatchingPoster}
contentFit="cover" contentFit="cover"
cachePolicy="memory-disk" cachePolicy="memory"
transition={150} transition={200}
placeholder={{ uri: 'https://via.placeholder.com/300x450' }} placeholder={{ uri: 'https://via.placeholder.com/300x450' }}
placeholderContentFit="cover" placeholderContentFit="cover"
recyclingKey={item.id} recyclingKey={item.id}
priority="high"
/> />
</View> </View>

View file

@ -426,111 +426,106 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
style={styles.featuredContainer as ViewStyle} style={styles.featuredContainer as ViewStyle}
> >
<Animated.View style={[styles.imageContainer, posterAnimatedStyle]}> <Animated.View style={[styles.imageContainer, posterAnimatedStyle]}>
<ExpoImage <ImageBackground
source={{ uri: bannerUrl || featuredContent.poster }} source={{ uri: bannerUrl || featuredContent.poster }}
style={styles.featuredImage} style={styles.featuredImage as ViewStyle}
contentFit="cover" resizeMode="cover"
cachePolicy="memory-disk"
transition={300}
priority="high"
placeholder={{ uri: 'https://via.placeholder.com/1080x1920' }}
placeholderContentFit="cover"
recyclingKey={featuredContent.id}
/>
{/* Subtle content overlay for better readability */}
<Animated.View style={[styles.contentOverlay, overlayAnimatedStyle]} />
<LinearGradient
colors={[
'rgba(0,0,0,0.1)',
'rgba(0,0,0,0.2)',
'rgba(0,0,0,0.4)',
'rgba(0,0,0,0.8)',
currentTheme.colors.darkBackground,
]}
locations={[0, 0.2, 0.5, 0.8, 1]}
style={styles.featuredGradient as ViewStyle}
> >
<Animated.View {/* Subtle content overlay for better readability */}
style={[styles.featuredContentContainer as ViewStyle, contentAnimatedStyle]} <Animated.View style={[styles.contentOverlay, overlayAnimatedStyle]} />
<LinearGradient
colors={[
'rgba(0,0,0,0.1)',
'rgba(0,0,0,0.2)',
'rgba(0,0,0,0.4)',
'rgba(0,0,0,0.8)',
currentTheme.colors.darkBackground,
]}
locations={[0, 0.2, 0.5, 0.8, 1]}
style={styles.featuredGradient as ViewStyle}
> >
{logoUrl && !logoLoadError ? ( <Animated.View
<Animated.View style={logoAnimatedStyle}> style={[styles.featuredContentContainer as ViewStyle, contentAnimatedStyle]}
<ExpoImage >
source={{ uri: logoUrl }} {logoUrl && !logoLoadError ? (
style={styles.featuredLogo as ImageStyle} <Animated.View style={logoAnimatedStyle}>
contentFit="contain" <ExpoImage
cachePolicy="memory" source={{ uri: logoUrl }}
transition={300} style={styles.featuredLogo as ImageStyle}
recyclingKey={`logo-${featuredContent.id}`} contentFit="contain"
onError={onLogoLoadError} cachePolicy="memory"
transition={300}
recyclingKey={`logo-${featuredContent.id}`}
onError={onLogoLoadError}
/>
</Animated.View>
) : (
<Text style={[styles.featuredTitleText as TextStyle, { color: currentTheme.colors.highEmphasis }]}>
{featuredContent.name}
</Text>
)}
<View style={styles.genreContainer as ViewStyle}>
{featuredContent.genres?.slice(0, 3).map((genre, index, array) => (
<React.Fragment key={index}>
<Text style={[styles.genreText as TextStyle, { color: currentTheme.colors.white }]}>
{genre}
</Text>
{index < array.length - 1 && (
<Text style={[styles.genreDot as TextStyle, { color: currentTheme.colors.white }]}></Text>
)}
</React.Fragment>
))}
</View>
</Animated.View>
<Animated.View style={[styles.featuredButtons as ViewStyle, buttonsAnimatedStyle]}>
<TouchableOpacity
style={styles.myListButton as ViewStyle}
onPress={handleSaveToLibrary}
activeOpacity={0.7}
>
<MaterialIcons
name={isSaved ? "bookmark" : "bookmark-border"}
size={24}
color={currentTheme.colors.white}
/> />
</Animated.View> <Text style={[styles.myListButtonText as TextStyle, { color: currentTheme.colors.white }]}>
) : ( {isSaved ? "Saved" : "Save"}
<Text style={[styles.featuredTitleText as TextStyle, { color: currentTheme.colors.highEmphasis }]}> </Text>
{featuredContent.name} </TouchableOpacity>
</Text>
)}
<View style={styles.genreContainer as ViewStyle}>
{featuredContent.genres?.slice(0, 3).map((genre, index, array) => (
<React.Fragment key={index}>
<Text style={[styles.genreText as TextStyle, { color: currentTheme.colors.white }]}>
{genre}
</Text>
{index < array.length - 1 && (
<Text style={[styles.genreDot as TextStyle, { color: currentTheme.colors.white }]}></Text>
)}
</React.Fragment>
))}
</View>
</Animated.View>
<Animated.View style={[styles.featuredButtons as ViewStyle, buttonsAnimatedStyle]}> <TouchableOpacity
<TouchableOpacity style={[styles.playButton as ViewStyle, { backgroundColor: currentTheme.colors.white }]}
style={styles.myListButton as ViewStyle} onPress={() => {
onPress={handleSaveToLibrary} if (featuredContent) {
activeOpacity={0.7} navigation.navigate('Streams', {
> id: featuredContent.id,
<MaterialIcons type: featuredContent.type
name={isSaved ? "bookmark" : "bookmark-border"} });
size={24} }
color={currentTheme.colors.white} }}
/> activeOpacity={0.8}
<Text style={[styles.myListButtonText as TextStyle, { color: currentTheme.colors.white }]}> >
{isSaved ? "Saved" : "Save"} <MaterialIcons name="play-arrow" size={24} color={currentTheme.colors.black} />
</Text> <Text style={[styles.playButtonText as TextStyle, { color: currentTheme.colors.black }]}>
</TouchableOpacity> Play
</Text>
</TouchableOpacity>
<TouchableOpacity <TouchableOpacity
style={[styles.playButton as ViewStyle, { backgroundColor: currentTheme.colors.white }]} style={styles.infoButton as ViewStyle}
onPress={() => { onPress={handleInfoPress}
if (featuredContent) { activeOpacity={0.7}
navigation.navigate('Streams', { >
id: featuredContent.id, <MaterialIcons name="info-outline" size={24} color={currentTheme.colors.white} />
type: featuredContent.type <Text style={[styles.infoButtonText as TextStyle, { color: currentTheme.colors.white }]}>
}); Info
} </Text>
}} </TouchableOpacity>
activeOpacity={0.8} </Animated.View>
> </LinearGradient>
<MaterialIcons name="play-arrow" size={24} color={currentTheme.colors.black} /> </ImageBackground>
<Text style={[styles.playButtonText as TextStyle, { color: currentTheme.colors.black }]}>
Play
</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.infoButton as ViewStyle}
onPress={handleInfoPress}
activeOpacity={0.7}
>
<MaterialIcons name="info-outline" size={24} color={currentTheme.colors.white} />
<Text style={[styles.infoButtonText as TextStyle, { color: currentTheme.colors.white }]}>
Info
</Text>
</TouchableOpacity>
</Animated.View>
</LinearGradient>
</Animated.View> </Animated.View>
</TouchableOpacity> </TouchableOpacity>
</Animated.View> </Animated.View>

View file

@ -130,15 +130,10 @@ export const ThisWeekSection = () => {
// Load episodes when library items change // Load episodes when library items change
useEffect(() => { useEffect(() => {
if (!libraryLoading) { if (!libraryLoading) {
// Only fetch if we have library items or if this is the first load console.log('[ThisWeekSection] Library items changed, refreshing episodes. Items count:', libraryItems.length);
if (libraryItems.length > 0 || episodes.length === 0) { fetchThisWeekEpisodes();
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, episodes.length]); }, [libraryLoading, libraryItems, fetchThisWeekEpisodes]);
const handleEpisodePress = (episode: ThisWeekEpisode) => { const handleEpisodePress = (episode: ThisWeekEpisode) => {
// For upcoming episodes, go to the metadata screen // For upcoming episodes, go to the metadata screen

View file

@ -242,37 +242,16 @@ export function useFeaturedContent() {
// Load featured content initially and when content source changes // Load featured content initially and when content source changes
useEffect(() => { useEffect(() => {
if (!settings.showHeroSection) { // Force refresh when switching to catalogs or when catalog selection changes
setFeaturedContent(null); if (contentSource === 'catalogs') {
// Clear cache when switching to catalogs mode
setAllFeaturedContent([]); setAllFeaturedContent([]);
setLoading(false); setFeaturedContent(null);
return; persistentStore.allFeaturedContent = [];
} persistentStore.featuredContent = null;
loadFeaturedContent(true);
// Only load if we don't have cached data or if settings actually changed } else if (contentSource === 'tmdb' && contentSource !== persistentStore.featuredContent?.type) {
const now = Date.now(); // Clear cache when switching to TMDB mode from catalogs
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([]); setAllFeaturedContent([]);
setFeaturedContent(null); setFeaturedContent(null);
persistentStore.allFeaturedContent = []; persistentStore.allFeaturedContent = [];
@ -282,7 +261,7 @@ export function useFeaturedContent() {
// Normal load (might use cache if available) // Normal load (might use cache if available)
loadFeaturedContent(false); loadFeaturedContent(false);
} }
}, [loadFeaturedContent, contentSource, selectedCatalogs, settings.showHeroSection]); }, [loadFeaturedContent, contentSource, selectedCatalogs]);
useEffect(() => { useEffect(() => {
if (featuredContent) { if (featuredContent) {

View file

@ -325,34 +325,35 @@ const HomeScreen = () => {
if (!content.length) return; if (!content.length) return;
try { try {
// More conservative prefetching to prevent memory issues // Limit concurrent prefetching to prevent memory pressure
const BATCH_SIZE = 2; const MAX_CONCURRENT_PREFETCH = 5;
const BATCH_SIZE = 3;
// Only prefetch poster images (most important) and limit to 6 items const allImages = content.slice(0, 10) // Limit total images to prefetch
const posterImages = content.slice(0, 6) .map(item => [item.poster, item.banner, item.logo])
.map(item => item.poster) .flat()
.filter(Boolean) as string[]; .filter(Boolean) as string[];
// Process in small batches with longer delays // Process in small batches to prevent memory pressure
for (let i = 0; i < posterImages.length; i += BATCH_SIZE) { for (let i = 0; i < allImages.length; i += BATCH_SIZE) {
const batch = posterImages.slice(i, i + BATCH_SIZE); const batch = allImages.slice(i, i + BATCH_SIZE);
try { try {
await Promise.all( await Promise.all(
batch.map(async (imageUrl) => { batch.map(async (imageUrl) => {
try { try {
await ExpoImage.prefetch(imageUrl); await ExpoImage.prefetch(imageUrl);
// Longer delay between prefetches // Small delay between prefetches to reduce memory pressure
await new Promise(resolve => setTimeout(resolve, 50)); await new Promise(resolve => setTimeout(resolve, 10));
} catch (error) { } catch (error) {
// Silently handle individual prefetch errors // Silently handle individual prefetch errors
} }
}) })
); );
// Longer delay between batches to allow GC // Delay between batches to allow GC
if (i + BATCH_SIZE < posterImages.length) { if (i + BATCH_SIZE < allImages.length) {
await new Promise(resolve => setTimeout(resolve, 200)); await new Promise(resolve => setTimeout(resolve, 50));
} }
} catch (error) { } catch (error) {
// Continue with next batch if current batch fails // Continue with next batch if current batch fails
@ -371,10 +372,18 @@ const HomeScreen = () => {
if (!featuredContent) return; if (!featuredContent) return;
try { 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 // Lock orientation to landscape before navigation to prevent glitches
// Don't clear image cache - let ExpoImage manage memory efficiently
try { try {
await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE); await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE);
// Longer delay to ensure orientation is fully set before navigation // Longer delay to ensure orientation is fully set before navigation
await new Promise(resolve => setTimeout(resolve, 200)); await new Promise(resolve => setTimeout(resolve, 200));
@ -382,7 +391,7 @@ const HomeScreen = () => {
// If orientation lock fails, continue anyway but log it // If orientation lock fails, continue anyway but log it
logger.warn('[HomeScreen] Orientation lock failed:', orientationError); logger.warn('[HomeScreen] Orientation lock failed:', orientationError);
// Still add a small delay // Still add a small delay
await new Promise(resolve => setTimeout(resolve, 100)); await new Promise(resolve => setTimeout(resolve, 100));
} }
navigation.navigate('Player', { navigation.navigate('Player', {
@ -425,23 +434,17 @@ const HomeScreen = () => {
useEffect(() => { useEffect(() => {
const unsubscribe = navigation.addListener('focus', () => { const unsubscribe = navigation.addListener('focus', () => {
// Only refresh continue watching section if it's empty (first load or error state) // Only refresh continue watching section on focus
if (continueWatchingRef.current) { refreshContinueWatching();
// 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 // Don't reload catalogs unless they haven't been loaded yet
// Catalogs will be refreshed through context updates when addons change // Catalogs will be refreshed through context updates when addons change
if (catalogs.length === 0 && !catalogsLoading) { if (catalogs.length === 0 && !catalogsLoading) {
console.log('[HomeScreen] Loading catalogs for first time');
loadCatalogsProgressively(); loadCatalogsProgressively();
} }
}); });
return unsubscribe; return unsubscribe;
}, [navigation, loadCatalogsProgressively, catalogs.length, catalogsLoading]); }, [navigation, refreshContinueWatching, loadCatalogsProgressively, catalogs.length, catalogsLoading]);
// Memoize the loading screen to prevent unnecessary re-renders // Memoize the loading screen to prevent unnecessary re-renders
const renderLoadingScreen = useMemo(() => { const renderLoadingScreen = useMemo(() => {

View file

@ -1,5 +1,4 @@
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import AsyncStorage from '@react-native-async-storage/async-storage';
interface CachedImage { interface CachedImage {
url: string; url: string;
@ -8,55 +7,10 @@ interface CachedImage {
expiresAt: number; expiresAt: number;
} }
interface PersistentCacheEntry {
url: string;
timestamp: number;
expiresAt: number;
accessCount: number;
lastAccessed: number;
}
class ImageCacheService { class ImageCacheService {
private cache = new Map<string, CachedImage>(); private cache = new Map<string, CachedImage>();
private persistentCache = new Map<string, PersistentCacheEntry>(); private readonly CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 hours
private readonly CACHE_DURATION = 7 * 24 * 60 * 60 * 1000; // 7 days (longer cache) private readonly MAX_CACHE_SIZE = 100; // Maximum number of cached images
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<void> {
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<void> {
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 * 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 return originalUrl; // Don't cache placeholder images
} }
await this.initialize(); // Check if we have a valid cached version
// Check memory cache first (fastest)
const cached = this.cache.get(originalUrl); const cached = this.cache.get(originalUrl);
if (cached && cached.expiresAt > Date.now()) { if (cached && cached.expiresAt > Date.now()) {
logger.log(`[ImageCache] Retrieved from memory cache: ${originalUrl}`); logger.log(`[ImageCache] Retrieved from cache: ${originalUrl}`);
return cached.localPath; 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 { try {
// Create new cache entry // For now, return the original URL but mark it as cached
const now = Date.now(); // In a production app, you would implement actual local caching here
const expiresAt = now + this.CACHE_DURATION;
const cachedImage: CachedImage = { const cachedImage: CachedImage = {
url: originalUrl, url: originalUrl,
localPath: originalUrl, localPath: originalUrl, // In production, this would be a local file path
timestamp: now, timestamp: Date.now(),
expiresAt, expiresAt: Date.now() + this.CACHE_DURATION,
};
const persistentEntry: PersistentCacheEntry = {
url: originalUrl,
timestamp: now,
expiresAt,
accessCount: 1,
lastAccessed: now,
}; };
this.cache.set(originalUrl, cachedImage); this.cache.set(originalUrl, cachedImage);
this.persistentCache.set(originalUrl, persistentEntry);
this.enforceMaxCacheSize(); this.enforceMaxCacheSize();
// Save persistent cache periodically (every 10 new entries) logger.log(`[ImageCache] ✅ NEW CACHE ENTRY: ${originalUrl} (Cache size: ${this.cache.size})`);
if (this.persistentCache.size % 10 === 0) {
this.savePersistentCache();
}
logger.log(`[ImageCache] ✅ NEW CACHE ENTRY: ${originalUrl} (Memory: ${this.cache.size}, Persistent: ${this.persistentCache.size})`);
return cachedImage.localPath; return cachedImage.localPath;
} catch (error) { } catch (error) {
logger.error('[ImageCache] Failed to cache image:', error); logger.error('[ImageCache] Failed to cache image:', error);
@ -136,82 +51,44 @@ class ImageCacheService {
/** /**
* Check if an image is cached * Check if an image is cached
*/ */
public async isCached(url: string): Promise<boolean> { public isCached(url: string): boolean {
await this.initialize();
const cached = this.cache.get(url); const cached = this.cache.get(url);
if (cached && cached.expiresAt > Date.now()) { return cached !== undefined && cached.expiresAt > Date.now();
return true;
}
const persistent = this.persistentCache.get(url);
return persistent !== undefined && persistent.expiresAt > Date.now();
} }
/** /**
* Log cache status (for debugging) * Log cache status (for debugging)
*/ */
public async logCacheStatus(): Promise<void> { public logCacheStatus(): void {
await this.initialize(); const stats = this.getCacheStats();
logger.log(`[ImageCache] 📊 Cache Status: ${stats.size} total, ${stats.expired} expired`);
const memoryStats = this.getCacheStats(); // Log first 5 cached URLs for debugging
const persistentCount = this.persistentCache.size; const entries = Array.from(this.cache.entries()).slice(0, 5);
const persistentExpired = Array.from(this.persistentCache.values()) entries.forEach(([url, cached]) => {
.filter(entry => entry.expiresAt <= Date.now()).length; const isExpired = cached.expiresAt <= Date.now();
const timeLeft = Math.max(0, cached.expiresAt - Date.now()) / 1000 / 60; // minutes
logger.log(`[ImageCache] 📊 Memory Cache: ${memoryStats.size} total, ${memoryStats.expired} expired`); logger.log(`[ImageCache] - ${url.substring(0, 60)}... (${isExpired ? 'EXPIRED' : `${timeLeft.toFixed(1)}m left`})`);
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 * Clear expired cache entries
*/ */
public async clearExpiredCache(): Promise<void> { public clearExpiredCache(): void {
await this.initialize();
const now = Date.now(); const now = Date.now();
let removedMemory = 0;
let removedPersistent = 0;
// Clear memory cache
for (const [url, cached] of this.cache.entries()) { for (const [url, cached] of this.cache.entries()) {
if (cached.expiresAt <= now) { if (cached.expiresAt <= now) {
this.cache.delete(url); 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 * Clear all cached images
*/ */
public async clearAllCache(): Promise<void> { public clearAllCache(): void {
this.cache.clear(); this.cache.clear();
this.persistentCache.clear();
await AsyncStorage.removeItem(this.PERSISTENT_CACHE_KEY);
logger.log('[ImageCache] Cleared all cached images'); logger.log('[ImageCache] Cleared all cached images');
} }
@ -235,39 +112,25 @@ class ImageCacheService {
} }
/** /**
* Enforce maximum cache size by removing oldest/least accessed entries * Enforce maximum cache size by removing oldest entries
*/ */
private enforceMaxCacheSize(): void { private enforceMaxCacheSize(): void {
// Enforce memory cache limit if (this.cache.size <= this.MAX_CACHE_SIZE) {
if (this.cache.size > this.MAX_CACHE_SIZE) { return;
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) // Convert to array and sort by timestamp (oldest first)
const persistentLimit = this.MAX_CACHE_SIZE * 3; const entries = Array.from(this.cache.entries()).sort(
if (this.persistentCache.size > persistentLimit) { (a, b) => a[1].timestamp - b[1].timestamp
// 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; // Remove oldest entries
for (let i = 0; i < toRemove; i++) { const toRemove = this.cache.size - this.MAX_CACHE_SIZE;
this.persistentCache.delete(entries[i][0]); 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`);
} }
} }

View file

@ -6,8 +6,7 @@ const CATALOG_CUSTOM_NAMES_KEY = 'catalog_custom_names';
// Initialize cache as an empty object // Initialize cache as an empty object
let customNamesCache: { [key: string]: string } = {}; let customNamesCache: { [key: string]: string } = {};
let cacheTimestamp: number = 0; // 0 indicates cache is invalid/empty initially let cacheTimestamp: number = 0; // 0 indicates cache is invalid/empty initially
let isLoading: boolean = false; // Prevent multiple concurrent loads const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
const CACHE_DURATION = 30 * 60 * 1000; // 30 minutes (longer cache for less frequent loads)
async function loadCustomNamesIfNeeded(): Promise<{ [key: string]: string }> { async function loadCustomNamesIfNeeded(): Promise<{ [key: string]: string }> {
const now = Date.now(); 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 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 { try {
logger.info('Loading custom catalog names from storage...'); logger.info('Loading custom catalog names from storage...');
const savedCustomNamesJson = await AsyncStorage.getItem(CATALOG_CUSTOM_NAMES_KEY); const savedCustomNamesJson = await AsyncStorage.getItem(CATALOG_CUSTOM_NAMES_KEY);
@ -36,8 +28,6 @@ async function loadCustomNamesIfNeeded(): Promise<{ [key: string]: string }> {
cacheTimestamp = 0; cacheTimestamp = 0;
// Return the last known cache (which might be empty {}), or a fresh empty object // 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 {} return customNamesCache || {}; // Return cache (could be outdated but non-null) or empty {}
} finally {
isLoading = false;
} }
} }