mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-01-11 20:10:25 +00:00
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:
parent
15767886b3
commit
14dd507d50
8 changed files with 179 additions and 356 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -309,12 +309,11 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((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"
|
||||
/>
|
||||
</View>
|
||||
|
||||
|
|
|
|||
|
|
@ -426,111 +426,106 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
|
|||
style={styles.featuredContainer as ViewStyle}
|
||||
>
|
||||
<Animated.View style={[styles.imageContainer, posterAnimatedStyle]}>
|
||||
<ExpoImage
|
||||
<ImageBackground
|
||||
source={{ uri: bannerUrl || featuredContent.poster }}
|
||||
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}
|
||||
style={styles.featuredImage as ViewStyle}
|
||||
resizeMode="cover"
|
||||
>
|
||||
<Animated.View
|
||||
style={[styles.featuredContentContainer as ViewStyle, contentAnimatedStyle]}
|
||||
{/* 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}
|
||||
>
|
||||
{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
|
||||
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}
|
||||
/>
|
||||
</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>
|
||||
<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 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>
|
||||
<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>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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<string, CachedImage>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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();
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue