Enhance image handling and caching strategies across multiple components for improved performance

This update modifies the image handling in ContentItem, ContinueWatchingSection, and FeaturedContent components to utilize a more efficient caching strategy with memory-disk policy and adjusted transition durations. Additionally, the HomeScreen component has been optimized for image prefetching, reducing memory pressure and improving overall performance. These changes aim to create a smoother user experience while navigating through content.
This commit is contained in:
tapframe 2025-06-21 23:47:41 +05:30
parent 6b63bc3d7f
commit 15767886b3
11 changed files with 390 additions and 207 deletions

View file

@ -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);

View file

@ -309,11 +309,12 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((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"
/>
</View>

View file

@ -426,106 +426,111 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
style={styles.featuredContainer as ViewStyle}
>
<Animated.View style={[styles.imageContainer, posterAnimatedStyle]}>
<ImageBackground
<ExpoImage
source={{ uri: bannerUrl || featuredContent.poster }}
style={styles.featuredImage as ViewStyle}
resizeMode="cover"
style={styles.featuredImage}
contentFit="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}
>
{/* 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
style={[styles.featuredContentContainer as ViewStyle, contentAnimatedStyle]}
>
<Animated.View
style={[styles.featuredContentContainer as ViewStyle, contentAnimatedStyle]}
>
{logoUrl && !logoLoadError ? (
<Animated.View style={logoAnimatedStyle}>
<ExpoImage
source={{ uri: logoUrl }}
style={styles.featuredLogo as ImageStyle}
contentFit="contain"
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}
{logoUrl && !logoLoadError ? (
<Animated.View style={logoAnimatedStyle}>
<ExpoImage
source={{ uri: logoUrl }}
style={styles.featuredLogo as ImageStyle}
contentFit="contain"
cachePolicy="memory"
transition={300}
recyclingKey={`logo-${featuredContent.id}`}
onError={onLogoLoadError}
/>
<Text style={[styles.myListButtonText as TextStyle, { color: currentTheme.colors.white }]}>
{isSaved ? "Saved" : "Save"}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.playButton as ViewStyle, { backgroundColor: currentTheme.colors.white }]}
onPress={() => {
if (featuredContent) {
navigation.navigate('Streams', {
id: featuredContent.id,
type: featuredContent.type
});
}
}}
activeOpacity={0.8}
>
<MaterialIcons name="play-arrow" size={24} color={currentTheme.colors.black} />
<Text style={[styles.playButtonText as TextStyle, { color: currentTheme.colors.black }]}>
Play
</Text>
</TouchableOpacity>
</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>
<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>
</ImageBackground>
<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}
/>
<Text style={[styles.myListButtonText as TextStyle, { color: currentTheme.colors.white }]}>
{isSaved ? "Saved" : "Save"}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.playButton as ViewStyle, { backgroundColor: currentTheme.colors.white }]}
onPress={() => {
if (featuredContent) {
navigation.navigate('Streams', {
id: featuredContent.id,
type: featuredContent.type
});
}
}}
activeOpacity={0.8}
>
<MaterialIcons name="play-arrow" size={24} color={currentTheme.colors.black} />
<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>
</TouchableOpacity>
</Animated.View>

View file

@ -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

View file

@ -71,7 +71,7 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
// 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<SeriesContentProps> = ({
// 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<SeriesContentProps> = ({
{/* Only render episode list if there are episodes */}
{currentSeasonEpisodes.length > 0 && (
settings.episodeLayoutStyle === 'horizontal' ? (
(settings?.episodeLayoutStyle === 'horizontal') ? (
// Horizontal Layout (Netflix-style)
<FlatList
ref={episodeScrollViewRef as React.RefObject<FlatList<any>>}

View file

@ -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) {

View file

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

View file

@ -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(() => {

View file

@ -97,22 +97,22 @@ const SettingItem: React.FC<SettingItemProps> = ({
styles.settingIconContainer,
{ backgroundColor: 'rgba(255,255,255,0.1)' }
]}>
<MaterialIcons name={icon} size={20} color={currentTheme.colors.primary} />
<MaterialIcons name={icon} size={Math.min(20, width * 0.05)} color={currentTheme.colors.primary} />
</View>
<View style={styles.settingContent}>
<View style={styles.settingTextContainer}>
<Text style={[styles.settingTitle, { color: currentTheme.colors.highEmphasis }]}>
<Text style={[styles.settingTitle, { color: currentTheme.colors.highEmphasis }]} numberOfLines={1} adjustsFontSizeToFit>
{title}
</Text>
{description && (
<Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}>
<Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]} numberOfLines={2} adjustsFontSizeToFit>
{description}
</Text>
)}
</View>
{badge && (
<View style={[styles.badge, { backgroundColor: currentTheme.colors.primary }]}>
<Text style={styles.badgeText}>{badge}</Text>
<Text style={styles.badgeText} numberOfLines={1} adjustsFontSizeToFit>{String(badge)}</Text>
</View>
)}
</View>
@ -395,14 +395,14 @@ const SettingsScreen: React.FC = () => {
/>
<SettingItem
title="Episode Layout"
description={settings.episodeLayoutStyle === 'horizontal' ? 'Horizontal Cards' : 'Vertical List'}
description={settings?.episodeLayoutStyle === 'horizontal' ? 'Horizontal Cards' : 'Vertical List'}
icon="view-module"
renderControl={() => (
<View style={styles.selectorContainer}>
<TouchableOpacity
style={[
styles.selectorButton,
settings.episodeLayoutStyle === 'vertical' && {
settings?.episodeLayoutStyle === 'vertical' && {
backgroundColor: currentTheme.colors.primary
}
]}
@ -411,7 +411,7 @@ const SettingsScreen: React.FC = () => {
<Text style={[
styles.selectorText,
{ color: currentTheme.colors.mediumEmphasis },
settings.episodeLayoutStyle === 'vertical' && {
settings?.episodeLayoutStyle === 'vertical' && {
color: currentTheme.colors.white,
fontWeight: '600'
}
@ -420,7 +420,7 @@ const SettingsScreen: React.FC = () => {
<TouchableOpacity
style={[
styles.selectorButton,
settings.episodeLayoutStyle === 'horizontal' && {
settings?.episodeLayoutStyle === 'horizontal' && {
backgroundColor: currentTheme.colors.primary
}
]}
@ -429,7 +429,7 @@ const SettingsScreen: React.FC = () => {
<Text style={[
styles.selectorText,
{ color: currentTheme.colors.mediumEmphasis },
settings.episodeLayoutStyle === 'horizontal' && {
settings?.episodeLayoutStyle === 'horizontal' && {
color: currentTheme.colors.white,
fontWeight: '600'
}
@ -518,12 +518,12 @@ const SettingsScreen: React.FC = () => {
<SettingItem
title="Video Player"
description={Platform.OS === 'ios'
? (settings.preferredPlayer === 'internal'
? (settings?.preferredPlayer === 'internal'
? 'Built-in Player'
: settings.preferredPlayer
: settings?.preferredPlayer
? settings.preferredPlayer.toUpperCase()
: 'Built-in Player')
: (settings.useExternalPlayer ? 'External Player' : 'Built-in Player')
: (settings?.useExternalPlayer ? 'External Player' : 'Built-in Player')
}
icon="play-arrow"
renderControl={ChevronRight}
@ -619,7 +619,7 @@ const styles = StyleSheet.create({
flex: 1,
},
header: {
paddingHorizontal: 20,
paddingHorizontal: Math.max(16, width * 0.05),
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-end',
@ -628,7 +628,7 @@ const styles = StyleSheet.create({
zIndex: 2,
},
headerTitle: {
fontSize: 32,
fontSize: Math.min(32, width * 0.08),
fontWeight: '800',
letterSpacing: 0.3,
},
@ -654,11 +654,11 @@ const styles = StyleSheet.create({
fontSize: 13,
fontWeight: '600',
letterSpacing: 0.8,
marginLeft: 16,
marginLeft: Math.max(12, width * 0.04),
marginBottom: 8,
},
card: {
marginHorizontal: 16,
marginHorizontal: Math.max(12, width * 0.04),
borderRadius: 16,
overflow: 'hidden',
shadowColor: '#000',
@ -672,9 +672,9 @@ const styles = StyleSheet.create({
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 12,
paddingHorizontal: 16,
paddingHorizontal: Math.max(12, width * 0.04),
borderBottomWidth: 0.5,
minHeight: 58,
minHeight: Math.max(54, width * 0.14),
width: '100%',
},
settingItemBorder: {
@ -697,12 +697,12 @@ const styles = StyleSheet.create({
flex: 1,
},
settingTitle: {
fontSize: 16,
fontSize: Math.min(16, width * 0.042),
fontWeight: '500',
marginBottom: 3,
},
settingDescription: {
fontSize: 14,
fontSize: Math.min(14, width * 0.037),
opacity: 0.8,
},
settingControl: {
@ -744,8 +744,10 @@ const styles = StyleSheet.create({
borderRadius: 8,
overflow: 'hidden',
height: 36,
width: 180,
minWidth: 140,
maxWidth: 180,
marginRight: 8,
alignSelf: 'flex-end',
},
selectorButton: {
flex: 1,
@ -755,7 +757,7 @@ const styles = StyleSheet.create({
backgroundColor: 'rgba(255,255,255,0.08)',
},
selectorText: {
fontSize: 13,
fontSize: Math.min(13, width * 0.034),
fontWeight: '500',
textAlign: 'center',
},

View file

@ -1,4 +1,5 @@
import { logger } from '../utils/logger';
import AsyncStorage from '@react-native-async-storage/async-storage';
interface CachedImage {
url: string;
@ -7,10 +8,55 @@ interface CachedImage {
expiresAt: number;
}
interface PersistentCacheEntry {
url: string;
timestamp: number;
expiresAt: number;
accessCount: number;
lastAccessed: number;
}
class ImageCacheService {
private cache = new Map<string, CachedImage>();
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<string, PersistentCacheEntry>();
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<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
@ -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<boolean> {
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<void> {
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<void> {
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<void> {
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();
export const imageCacheService = new ImageCacheService();

View file

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