mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-04-21 00:32:04 +00:00
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:
parent
6b63bc3d7f
commit
15767886b3
11 changed files with 390 additions and 207 deletions
|
|
@ -102,11 +102,12 @@ 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"
|
cachePolicy="memory-disk"
|
||||||
transition={200}
|
transition={150}
|
||||||
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,11 +309,12 @@ 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"
|
cachePolicy="memory-disk"
|
||||||
transition={200}
|
transition={150}
|
||||||
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,106 +426,111 @@ 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]}>
|
||||||
<ImageBackground
|
<ExpoImage
|
||||||
source={{ uri: bannerUrl || featuredContent.poster }}
|
source={{ uri: bannerUrl || featuredContent.poster }}
|
||||||
style={styles.featuredImage as ViewStyle}
|
style={styles.featuredImage}
|
||||||
resizeMode="cover"
|
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
|
||||||
<Animated.View style={[styles.contentOverlay, overlayAnimatedStyle]} />
|
style={[styles.featuredContentContainer as ViewStyle, contentAnimatedStyle]}
|
||||||
|
|
||||||
<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
|
{logoUrl && !logoLoadError ? (
|
||||||
style={[styles.featuredContentContainer as ViewStyle, contentAnimatedStyle]}
|
<Animated.View style={logoAnimatedStyle}>
|
||||||
>
|
<ExpoImage
|
||||||
{logoUrl && !logoLoadError ? (
|
source={{ uri: logoUrl }}
|
||||||
<Animated.View style={logoAnimatedStyle}>
|
style={styles.featuredLogo as ImageStyle}
|
||||||
<ExpoImage
|
contentFit="contain"
|
||||||
source={{ uri: logoUrl }}
|
cachePolicy="memory"
|
||||||
style={styles.featuredLogo as ImageStyle}
|
transition={300}
|
||||||
contentFit="contain"
|
recyclingKey={`logo-${featuredContent.id}`}
|
||||||
cachePolicy="memory"
|
onError={onLogoLoadError}
|
||||||
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}
|
|
||||||
/>
|
/>
|
||||||
<Text style={[styles.myListButtonText as TextStyle, { color: currentTheme.colors.white }]}>
|
</Animated.View>
|
||||||
{isSaved ? "Saved" : "Save"}
|
) : (
|
||||||
</Text>
|
<Text style={[styles.featuredTitleText as TextStyle, { color: currentTheme.colors.highEmphasis }]}>
|
||||||
</TouchableOpacity>
|
{featuredContent.name}
|
||||||
|
</Text>
|
||||||
<TouchableOpacity
|
)}
|
||||||
style={[styles.playButton as ViewStyle, { backgroundColor: currentTheme.colors.white }]}
|
<View style={styles.genreContainer as ViewStyle}>
|
||||||
onPress={() => {
|
{featuredContent.genres?.slice(0, 3).map((genre, index, array) => (
|
||||||
if (featuredContent) {
|
<React.Fragment key={index}>
|
||||||
navigation.navigate('Streams', {
|
<Text style={[styles.genreText as TextStyle, { color: currentTheme.colors.white }]}>
|
||||||
id: featuredContent.id,
|
{genre}
|
||||||
type: featuredContent.type
|
</Text>
|
||||||
});
|
{index < array.length - 1 && (
|
||||||
}
|
<Text style={[styles.genreDot as TextStyle, { color: currentTheme.colors.white }]}>•</Text>
|
||||||
}}
|
)}
|
||||||
activeOpacity={0.8}
|
</React.Fragment>
|
||||||
>
|
))}
|
||||||
<MaterialIcons name="play-arrow" size={24} color={currentTheme.colors.black} />
|
</View>
|
||||||
<Text style={[styles.playButtonText as TextStyle, { color: currentTheme.colors.black }]}>
|
</Animated.View>
|
||||||
Play
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<TouchableOpacity
|
<Animated.View style={[styles.featuredButtons as ViewStyle, buttonsAnimatedStyle]}>
|
||||||
style={styles.infoButton as ViewStyle}
|
<TouchableOpacity
|
||||||
onPress={handleInfoPress}
|
style={styles.myListButton as ViewStyle}
|
||||||
activeOpacity={0.7}
|
onPress={handleSaveToLibrary}
|
||||||
>
|
activeOpacity={0.7}
|
||||||
<MaterialIcons name="info-outline" size={24} color={currentTheme.colors.white} />
|
>
|
||||||
<Text style={[styles.infoButtonText as TextStyle, { color: currentTheme.colors.white }]}>
|
<MaterialIcons
|
||||||
Info
|
name={isSaved ? "bookmark" : "bookmark-border"}
|
||||||
</Text>
|
size={24}
|
||||||
</TouchableOpacity>
|
color={currentTheme.colors.white}
|
||||||
</Animated.View>
|
/>
|
||||||
</LinearGradient>
|
<Text style={[styles.myListButtonText as TextStyle, { color: currentTheme.colors.white }]}>
|
||||||
</ImageBackground>
|
{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>
|
</Animated.View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
|
||||||
|
|
@ -130,10 +130,15 @@ export const ThisWeekSection = () => {
|
||||||
// Load episodes when library items change
|
// Load episodes when library items change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!libraryLoading) {
|
if (!libraryLoading) {
|
||||||
console.log('[ThisWeekSection] Library items changed, refreshing episodes. Items count:', libraryItems.length);
|
// Only fetch if we have library items or if this is the first load
|
||||||
fetchThisWeekEpisodes();
|
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) => {
|
const handleEpisodePress = (episode: ThisWeekEpisode) => {
|
||||||
// For upcoming episodes, go to the metadata screen
|
// For upcoming episodes, go to the metadata screen
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,7 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
|
||||||
|
|
||||||
// Function to find and scroll to the most recently watched episode
|
// Function to find and scroll to the most recently watched episode
|
||||||
const scrollToMostRecentEpisode = () => {
|
const scrollToMostRecentEpisode = () => {
|
||||||
if (!metadata?.id || !episodeScrollViewRef.current || settings.episodeLayoutStyle !== 'horizontal') {
|
if (!metadata?.id || !episodeScrollViewRef.current || !settings?.episodeLayoutStyle || settings.episodeLayoutStyle !== 'horizontal') {
|
||||||
return;
|
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
|
// Add effect to scroll to most recently watched episode when season changes or progress loads
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Object.keys(episodeProgress).length > 0 && selectedSeason) {
|
if (Object.keys(episodeProgress).length > 0 && selectedSeason && settings?.episodeLayoutStyle) {
|
||||||
scrollToMostRecentEpisode();
|
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 */}
|
{/* Only render episode list if there are episodes */}
|
||||||
{currentSeasonEpisodes.length > 0 && (
|
{currentSeasonEpisodes.length > 0 && (
|
||||||
settings.episodeLayoutStyle === 'horizontal' ? (
|
(settings?.episodeLayoutStyle === 'horizontal') ? (
|
||||||
// Horizontal Layout (Netflix-style)
|
// Horizontal Layout (Netflix-style)
|
||||||
<FlatList
|
<FlatList
|
||||||
ref={episodeScrollViewRef as React.RefObject<FlatList<any>>}
|
ref={episodeScrollViewRef as React.RefObject<FlatList<any>>}
|
||||||
|
|
|
||||||
|
|
@ -242,16 +242,37 @@ export function useFeaturedContent() {
|
||||||
|
|
||||||
// Load featured content initially and when content source changes
|
// Load featured content initially and when content source changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Force refresh when switching to catalogs or when catalog selection changes
|
if (!settings.showHeroSection) {
|
||||||
if (contentSource === 'catalogs') {
|
|
||||||
// Clear cache when switching to catalogs mode
|
|
||||||
setAllFeaturedContent([]);
|
|
||||||
setFeaturedContent(null);
|
setFeaturedContent(null);
|
||||||
persistentStore.allFeaturedContent = [];
|
setAllFeaturedContent([]);
|
||||||
persistentStore.featuredContent = null;
|
setLoading(false);
|
||||||
loadFeaturedContent(true);
|
return;
|
||||||
} else if (contentSource === 'tmdb' && contentSource !== persistentStore.featuredContent?.type) {
|
}
|
||||||
// Clear cache when switching to TMDB mode from catalogs
|
|
||||||
|
// 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([]);
|
setAllFeaturedContent([]);
|
||||||
setFeaturedContent(null);
|
setFeaturedContent(null);
|
||||||
persistentStore.allFeaturedContent = [];
|
persistentStore.allFeaturedContent = [];
|
||||||
|
|
@ -261,7 +282,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]);
|
}, [loadFeaturedContent, contentSource, selectedCatalogs, settings.showHeroSection]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (featuredContent) {
|
if (featuredContent) {
|
||||||
|
|
|
||||||
|
|
@ -78,10 +78,14 @@ export const useSettings = () => {
|
||||||
try {
|
try {
|
||||||
const storedSettings = await AsyncStorage.getItem(SETTINGS_STORAGE_KEY);
|
const storedSettings = await AsyncStorage.getItem(SETTINGS_STORAGE_KEY);
|
||||||
if (storedSettings) {
|
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) {
|
} catch (error) {
|
||||||
console.error('Failed to load settings:', error);
|
console.error('Failed to load settings:', error);
|
||||||
|
// Fallback to default settings on error
|
||||||
|
setSettings(DEFAULT_SETTINGS);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -325,35 +325,34 @@ const HomeScreen = () => {
|
||||||
if (!content.length) return;
|
if (!content.length) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Limit concurrent prefetching to prevent memory pressure
|
// More conservative prefetching to prevent memory issues
|
||||||
const MAX_CONCURRENT_PREFETCH = 5;
|
const BATCH_SIZE = 2;
|
||||||
const BATCH_SIZE = 3;
|
|
||||||
|
|
||||||
const allImages = content.slice(0, 10) // Limit total images to prefetch
|
// Only prefetch poster images (most important) and limit to 6 items
|
||||||
.map(item => [item.poster, item.banner, item.logo])
|
const posterImages = content.slice(0, 6)
|
||||||
.flat()
|
.map(item => item.poster)
|
||||||
.filter(Boolean) as string[];
|
.filter(Boolean) as string[];
|
||||||
|
|
||||||
// Process in small batches to prevent memory pressure
|
// Process in small batches with longer delays
|
||||||
for (let i = 0; i < allImages.length; i += BATCH_SIZE) {
|
for (let i = 0; i < posterImages.length; i += BATCH_SIZE) {
|
||||||
const batch = allImages.slice(i, i + BATCH_SIZE);
|
const batch = posterImages.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);
|
||||||
// Small delay between prefetches to reduce memory pressure
|
// Longer delay between prefetches
|
||||||
await new Promise(resolve => setTimeout(resolve, 10));
|
await new Promise(resolve => setTimeout(resolve, 50));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Silently handle individual prefetch errors
|
// Silently handle individual prefetch errors
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Delay between batches to allow GC
|
// Longer delay between batches to allow GC
|
||||||
if (i + BATCH_SIZE < allImages.length) {
|
if (i + BATCH_SIZE < posterImages.length) {
|
||||||
await new Promise(resolve => setTimeout(resolve, 50));
|
await new Promise(resolve => setTimeout(resolve, 200));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Continue with next batch if current batch fails
|
// Continue with next batch if current batch fails
|
||||||
|
|
@ -372,26 +371,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));
|
||||||
} catch (orientationError) {
|
} catch (orientationError) {
|
||||||
// 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', {
|
||||||
|
|
@ -434,17 +425,23 @@ const HomeScreen = () => {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubscribe = navigation.addListener('focus', () => {
|
const unsubscribe = navigation.addListener('focus', () => {
|
||||||
// Only refresh continue watching section on focus
|
// Only refresh continue watching section if it's empty (first load or error state)
|
||||||
refreshContinueWatching();
|
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
|
// 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, refreshContinueWatching, loadCatalogsProgressively, catalogs.length, catalogsLoading]);
|
}, [navigation, 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(() => {
|
||||||
|
|
|
||||||
|
|
@ -97,22 +97,22 @@ const SettingItem: React.FC<SettingItemProps> = ({
|
||||||
styles.settingIconContainer,
|
styles.settingIconContainer,
|
||||||
{ backgroundColor: 'rgba(255,255,255,0.1)' }
|
{ 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>
|
||||||
<View style={styles.settingContent}>
|
<View style={styles.settingContent}>
|
||||||
<View style={styles.settingTextContainer}>
|
<View style={styles.settingTextContainer}>
|
||||||
<Text style={[styles.settingTitle, { color: currentTheme.colors.highEmphasis }]}>
|
<Text style={[styles.settingTitle, { color: currentTheme.colors.highEmphasis }]} numberOfLines={1} adjustsFontSizeToFit>
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
{description && (
|
{description && (
|
||||||
<Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}>
|
<Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]} numberOfLines={2} adjustsFontSizeToFit>
|
||||||
{description}
|
{description}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
{badge && (
|
{badge && (
|
||||||
<View style={[styles.badge, { backgroundColor: currentTheme.colors.primary }]}>
|
<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>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
@ -395,14 +395,14 @@ const SettingsScreen: React.FC = () => {
|
||||||
/>
|
/>
|
||||||
<SettingItem
|
<SettingItem
|
||||||
title="Episode Layout"
|
title="Episode Layout"
|
||||||
description={settings.episodeLayoutStyle === 'horizontal' ? 'Horizontal Cards' : 'Vertical List'}
|
description={settings?.episodeLayoutStyle === 'horizontal' ? 'Horizontal Cards' : 'Vertical List'}
|
||||||
icon="view-module"
|
icon="view-module"
|
||||||
renderControl={() => (
|
renderControl={() => (
|
||||||
<View style={styles.selectorContainer}>
|
<View style={styles.selectorContainer}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[
|
style={[
|
||||||
styles.selectorButton,
|
styles.selectorButton,
|
||||||
settings.episodeLayoutStyle === 'vertical' && {
|
settings?.episodeLayoutStyle === 'vertical' && {
|
||||||
backgroundColor: currentTheme.colors.primary
|
backgroundColor: currentTheme.colors.primary
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
|
|
@ -411,7 +411,7 @@ const SettingsScreen: React.FC = () => {
|
||||||
<Text style={[
|
<Text style={[
|
||||||
styles.selectorText,
|
styles.selectorText,
|
||||||
{ color: currentTheme.colors.mediumEmphasis },
|
{ color: currentTheme.colors.mediumEmphasis },
|
||||||
settings.episodeLayoutStyle === 'vertical' && {
|
settings?.episodeLayoutStyle === 'vertical' && {
|
||||||
color: currentTheme.colors.white,
|
color: currentTheme.colors.white,
|
||||||
fontWeight: '600'
|
fontWeight: '600'
|
||||||
}
|
}
|
||||||
|
|
@ -420,7 +420,7 @@ const SettingsScreen: React.FC = () => {
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[
|
style={[
|
||||||
styles.selectorButton,
|
styles.selectorButton,
|
||||||
settings.episodeLayoutStyle === 'horizontal' && {
|
settings?.episodeLayoutStyle === 'horizontal' && {
|
||||||
backgroundColor: currentTheme.colors.primary
|
backgroundColor: currentTheme.colors.primary
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
|
|
@ -429,7 +429,7 @@ const SettingsScreen: React.FC = () => {
|
||||||
<Text style={[
|
<Text style={[
|
||||||
styles.selectorText,
|
styles.selectorText,
|
||||||
{ color: currentTheme.colors.mediumEmphasis },
|
{ color: currentTheme.colors.mediumEmphasis },
|
||||||
settings.episodeLayoutStyle === 'horizontal' && {
|
settings?.episodeLayoutStyle === 'horizontal' && {
|
||||||
color: currentTheme.colors.white,
|
color: currentTheme.colors.white,
|
||||||
fontWeight: '600'
|
fontWeight: '600'
|
||||||
}
|
}
|
||||||
|
|
@ -518,12 +518,12 @@ const SettingsScreen: React.FC = () => {
|
||||||
<SettingItem
|
<SettingItem
|
||||||
title="Video Player"
|
title="Video Player"
|
||||||
description={Platform.OS === 'ios'
|
description={Platform.OS === 'ios'
|
||||||
? (settings.preferredPlayer === 'internal'
|
? (settings?.preferredPlayer === 'internal'
|
||||||
? 'Built-in Player'
|
? 'Built-in Player'
|
||||||
: settings.preferredPlayer
|
: settings?.preferredPlayer
|
||||||
? settings.preferredPlayer.toUpperCase()
|
? settings.preferredPlayer.toUpperCase()
|
||||||
: 'Built-in Player')
|
: 'Built-in Player')
|
||||||
: (settings.useExternalPlayer ? 'External Player' : 'Built-in Player')
|
: (settings?.useExternalPlayer ? 'External Player' : 'Built-in Player')
|
||||||
}
|
}
|
||||||
icon="play-arrow"
|
icon="play-arrow"
|
||||||
renderControl={ChevronRight}
|
renderControl={ChevronRight}
|
||||||
|
|
@ -619,7 +619,7 @@ const styles = StyleSheet.create({
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
header: {
|
header: {
|
||||||
paddingHorizontal: 20,
|
paddingHorizontal: Math.max(16, width * 0.05),
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'flex-end',
|
alignItems: 'flex-end',
|
||||||
|
|
@ -628,7 +628,7 @@ const styles = StyleSheet.create({
|
||||||
zIndex: 2,
|
zIndex: 2,
|
||||||
},
|
},
|
||||||
headerTitle: {
|
headerTitle: {
|
||||||
fontSize: 32,
|
fontSize: Math.min(32, width * 0.08),
|
||||||
fontWeight: '800',
|
fontWeight: '800',
|
||||||
letterSpacing: 0.3,
|
letterSpacing: 0.3,
|
||||||
},
|
},
|
||||||
|
|
@ -654,11 +654,11 @@ const styles = StyleSheet.create({
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
letterSpacing: 0.8,
|
letterSpacing: 0.8,
|
||||||
marginLeft: 16,
|
marginLeft: Math.max(12, width * 0.04),
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
},
|
},
|
||||||
card: {
|
card: {
|
||||||
marginHorizontal: 16,
|
marginHorizontal: Math.max(12, width * 0.04),
|
||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
shadowColor: '#000',
|
shadowColor: '#000',
|
||||||
|
|
@ -672,9 +672,9 @@ const styles = StyleSheet.create({
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingVertical: 12,
|
paddingVertical: 12,
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: Math.max(12, width * 0.04),
|
||||||
borderBottomWidth: 0.5,
|
borderBottomWidth: 0.5,
|
||||||
minHeight: 58,
|
minHeight: Math.max(54, width * 0.14),
|
||||||
width: '100%',
|
width: '100%',
|
||||||
},
|
},
|
||||||
settingItemBorder: {
|
settingItemBorder: {
|
||||||
|
|
@ -697,12 +697,12 @@ const styles = StyleSheet.create({
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
settingTitle: {
|
settingTitle: {
|
||||||
fontSize: 16,
|
fontSize: Math.min(16, width * 0.042),
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
marginBottom: 3,
|
marginBottom: 3,
|
||||||
},
|
},
|
||||||
settingDescription: {
|
settingDescription: {
|
||||||
fontSize: 14,
|
fontSize: Math.min(14, width * 0.037),
|
||||||
opacity: 0.8,
|
opacity: 0.8,
|
||||||
},
|
},
|
||||||
settingControl: {
|
settingControl: {
|
||||||
|
|
@ -744,8 +744,10 @@ const styles = StyleSheet.create({
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
height: 36,
|
height: 36,
|
||||||
width: 180,
|
minWidth: 140,
|
||||||
|
maxWidth: 180,
|
||||||
marginRight: 8,
|
marginRight: 8,
|
||||||
|
alignSelf: 'flex-end',
|
||||||
},
|
},
|
||||||
selectorButton: {
|
selectorButton: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
|
@ -755,7 +757,7 @@ const styles = StyleSheet.create({
|
||||||
backgroundColor: 'rgba(255,255,255,0.08)',
|
backgroundColor: 'rgba(255,255,255,0.08)',
|
||||||
},
|
},
|
||||||
selectorText: {
|
selectorText: {
|
||||||
fontSize: 13,
|
fontSize: Math.min(13, width * 0.034),
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
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;
|
||||||
|
|
@ -7,10 +8,55 @@ 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 readonly CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 hours
|
private persistentCache = new Map<string, PersistentCacheEntry>();
|
||||||
private readonly MAX_CACHE_SIZE = 100; // Maximum number of cached images
|
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
|
* 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
|
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);
|
const cached = this.cache.get(originalUrl);
|
||||||
if (cached && cached.expiresAt > Date.now()) {
|
if (cached && cached.expiresAt > Date.now()) {
|
||||||
logger.log(`[ImageCache] Retrieved from cache: ${originalUrl}`);
|
logger.log(`[ImageCache] Retrieved from memory cache: ${originalUrl}`);
|
||||||
return cached.localPath;
|
return cached.localPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// Check persistent cache
|
||||||
// For now, return the original URL but mark it as cached
|
const persistent = this.persistentCache.get(originalUrl);
|
||||||
// In a production app, you would implement actual local caching here
|
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 = {
|
const cachedImage: CachedImage = {
|
||||||
url: originalUrl,
|
url: originalUrl,
|
||||||
localPath: originalUrl, // In production, this would be a local file path
|
localPath: originalUrl,
|
||||||
timestamp: Date.now(),
|
timestamp: persistent.timestamp,
|
||||||
expiresAt: Date.now() + this.CACHE_DURATION,
|
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.cache.set(originalUrl, cachedImage);
|
||||||
|
this.persistentCache.set(originalUrl, persistentEntry);
|
||||||
|
|
||||||
this.enforceMaxCacheSize();
|
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;
|
return cachedImage.localPath;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[ImageCache] Failed to cache image:', error);
|
logger.error('[ImageCache] Failed to cache image:', error);
|
||||||
|
|
@ -51,44 +136,82 @@ class ImageCacheService {
|
||||||
/**
|
/**
|
||||||
* Check if an image is cached
|
* 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);
|
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)
|
* Log cache status (for debugging)
|
||||||
*/
|
*/
|
||||||
public logCacheStatus(): void {
|
public async logCacheStatus(): Promise<void> {
|
||||||
const stats = this.getCacheStats();
|
await this.initialize();
|
||||||
logger.log(`[ImageCache] 📊 Cache Status: ${stats.size} total, ${stats.expired} expired`);
|
|
||||||
|
|
||||||
// Log first 5 cached URLs for debugging
|
const memoryStats = this.getCacheStats();
|
||||||
const entries = Array.from(this.cache.entries()).slice(0, 5);
|
const persistentCount = this.persistentCache.size;
|
||||||
entries.forEach(([url, cached]) => {
|
const persistentExpired = Array.from(this.persistentCache.values())
|
||||||
const isExpired = cached.expiresAt <= Date.now();
|
.filter(entry => entry.expiresAt <= Date.now()).length;
|
||||||
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`})`);
|
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
|
* Clear expired cache entries
|
||||||
*/
|
*/
|
||||||
public clearExpiredCache(): void {
|
public async clearExpiredCache(): Promise<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 clearAllCache(): void {
|
public async clearAllCache(): Promise<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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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 {
|
private enforceMaxCacheSize(): void {
|
||||||
if (this.cache.size <= this.MAX_CACHE_SIZE) {
|
// Enforce memory cache limit
|
||||||
return;
|
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 toRemove = this.persistentCache.size - persistentLimit;
|
||||||
const entries = Array.from(this.cache.entries()).sort(
|
for (let i = 0; i < toRemove; i++) {
|
||||||
(a, b) => a[1].timestamp - b[1].timestamp
|
this.persistentCache.delete(entries[i][0]);
|
||||||
);
|
}
|
||||||
|
|
||||||
// Remove oldest entries
|
this.savePersistentCache();
|
||||||
const toRemove = this.cache.size - this.MAX_CACHE_SIZE;
|
logger.log(`[ImageCache] Removed ${toRemove} old persistent entries to enforce cache size limit`);
|
||||||
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,7 +6,8 @@ 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
|
||||||
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 }> {
|
async function loadCustomNamesIfNeeded(): Promise<{ [key: string]: string }> {
|
||||||
const now = Date.now();
|
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
|
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);
|
||||||
|
|
@ -28,6 +36,8 @@ 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