mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-04-29 12:23:01 +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' }}
|
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);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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(() => {
|
||||||
|
|
|
||||||
|
|
@ -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`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue