mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-03-11 17:45:38 +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' }}
|
||||
style={styles.poster}
|
||||
contentFit="cover"
|
||||
cachePolicy="memory"
|
||||
transition={200}
|
||||
cachePolicy="memory-disk"
|
||||
transition={150}
|
||||
placeholder={{ uri: 'https://via.placeholder.com/300x450' }}
|
||||
placeholderContentFit="cover"
|
||||
recyclingKey={item.id}
|
||||
priority="high"
|
||||
onLoadStart={() => {
|
||||
setImageLoaded(false);
|
||||
setImageError(false);
|
||||
|
|
|
|||
|
|
@ -309,11 +309,12 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
source={{ uri: item.poster || 'https://via.placeholder.com/300x450' }}
|
||||
style={styles.continueWatchingPoster}
|
||||
contentFit="cover"
|
||||
cachePolicy="memory"
|
||||
transition={200}
|
||||
cachePolicy="memory-disk"
|
||||
transition={150}
|
||||
placeholder={{ uri: 'https://via.placeholder.com/300x450' }}
|
||||
placeholderContentFit="cover"
|
||||
recyclingKey={item.id}
|
||||
priority="high"
|
||||
/>
|
||||
</View>
|
||||
|
||||
|
|
|
|||
|
|
@ -426,106 +426,111 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
|
|||
style={styles.featuredContainer as ViewStyle}
|
||||
>
|
||||
<Animated.View style={[styles.imageContainer, posterAnimatedStyle]}>
|
||||
<ImageBackground
|
||||
<ExpoImage
|
||||
source={{ uri: bannerUrl || featuredContent.poster }}
|
||||
style={styles.featuredImage as ViewStyle}
|
||||
resizeMode="cover"
|
||||
style={styles.featuredImage}
|
||||
contentFit="cover"
|
||||
cachePolicy="memory-disk"
|
||||
transition={300}
|
||||
priority="high"
|
||||
placeholder={{ uri: 'https://via.placeholder.com/1080x1920' }}
|
||||
placeholderContentFit="cover"
|
||||
recyclingKey={featuredContent.id}
|
||||
/>
|
||||
{/* Subtle content overlay for better readability */}
|
||||
<Animated.View style={[styles.contentOverlay, overlayAnimatedStyle]} />
|
||||
|
||||
<LinearGradient
|
||||
colors={[
|
||||
'rgba(0,0,0,0.1)',
|
||||
'rgba(0,0,0,0.2)',
|
||||
'rgba(0,0,0,0.4)',
|
||||
'rgba(0,0,0,0.8)',
|
||||
currentTheme.colors.darkBackground,
|
||||
]}
|
||||
locations={[0, 0.2, 0.5, 0.8, 1]}
|
||||
style={styles.featuredGradient as ViewStyle}
|
||||
>
|
||||
{/* Subtle content overlay for better readability */}
|
||||
<Animated.View style={[styles.contentOverlay, overlayAnimatedStyle]} />
|
||||
|
||||
<LinearGradient
|
||||
colors={[
|
||||
'rgba(0,0,0,0.1)',
|
||||
'rgba(0,0,0,0.2)',
|
||||
'rgba(0,0,0,0.4)',
|
||||
'rgba(0,0,0,0.8)',
|
||||
currentTheme.colors.darkBackground,
|
||||
]}
|
||||
locations={[0, 0.2, 0.5, 0.8, 1]}
|
||||
style={styles.featuredGradient as ViewStyle}
|
||||
<Animated.View
|
||||
style={[styles.featuredContentContainer as ViewStyle, contentAnimatedStyle]}
|
||||
>
|
||||
<Animated.View
|
||||
style={[styles.featuredContentContainer as ViewStyle, contentAnimatedStyle]}
|
||||
>
|
||||
{logoUrl && !logoLoadError ? (
|
||||
<Animated.View style={logoAnimatedStyle}>
|
||||
<ExpoImage
|
||||
source={{ uri: logoUrl }}
|
||||
style={styles.featuredLogo as ImageStyle}
|
||||
contentFit="contain"
|
||||
cachePolicy="memory"
|
||||
transition={300}
|
||||
recyclingKey={`logo-${featuredContent.id}`}
|
||||
onError={onLogoLoadError}
|
||||
/>
|
||||
</Animated.View>
|
||||
) : (
|
||||
<Text style={[styles.featuredTitleText as TextStyle, { color: currentTheme.colors.highEmphasis }]}>
|
||||
{featuredContent.name}
|
||||
</Text>
|
||||
)}
|
||||
<View style={styles.genreContainer as ViewStyle}>
|
||||
{featuredContent.genres?.slice(0, 3).map((genre, index, array) => (
|
||||
<React.Fragment key={index}>
|
||||
<Text style={[styles.genreText as TextStyle, { color: currentTheme.colors.white }]}>
|
||||
{genre}
|
||||
</Text>
|
||||
{index < array.length - 1 && (
|
||||
<Text style={[styles.genreDot as TextStyle, { color: currentTheme.colors.white }]}>•</Text>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View style={[styles.featuredButtons as ViewStyle, buttonsAnimatedStyle]}>
|
||||
<TouchableOpacity
|
||||
style={styles.myListButton as ViewStyle}
|
||||
onPress={handleSaveToLibrary}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialIcons
|
||||
name={isSaved ? "bookmark" : "bookmark-border"}
|
||||
size={24}
|
||||
color={currentTheme.colors.white}
|
||||
{logoUrl && !logoLoadError ? (
|
||||
<Animated.View style={logoAnimatedStyle}>
|
||||
<ExpoImage
|
||||
source={{ uri: logoUrl }}
|
||||
style={styles.featuredLogo as ImageStyle}
|
||||
contentFit="contain"
|
||||
cachePolicy="memory"
|
||||
transition={300}
|
||||
recyclingKey={`logo-${featuredContent.id}`}
|
||||
onError={onLogoLoadError}
|
||||
/>
|
||||
<Text style={[styles.myListButtonText as TextStyle, { color: currentTheme.colors.white }]}>
|
||||
{isSaved ? "Saved" : "Save"}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.playButton as ViewStyle, { backgroundColor: currentTheme.colors.white }]}
|
||||
onPress={() => {
|
||||
if (featuredContent) {
|
||||
navigation.navigate('Streams', {
|
||||
id: featuredContent.id,
|
||||
type: featuredContent.type
|
||||
});
|
||||
}
|
||||
}}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<MaterialIcons name="play-arrow" size={24} color={currentTheme.colors.black} />
|
||||
<Text style={[styles.playButtonText as TextStyle, { color: currentTheme.colors.black }]}>
|
||||
Play
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
) : (
|
||||
<Text style={[styles.featuredTitleText as TextStyle, { color: currentTheme.colors.highEmphasis }]}>
|
||||
{featuredContent.name}
|
||||
</Text>
|
||||
)}
|
||||
<View style={styles.genreContainer as ViewStyle}>
|
||||
{featuredContent.genres?.slice(0, 3).map((genre, index, array) => (
|
||||
<React.Fragment key={index}>
|
||||
<Text style={[styles.genreText as TextStyle, { color: currentTheme.colors.white }]}>
|
||||
{genre}
|
||||
</Text>
|
||||
{index < array.length - 1 && (
|
||||
<Text style={[styles.genreDot as TextStyle, { color: currentTheme.colors.white }]}>•</Text>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.infoButton as ViewStyle}
|
||||
onPress={handleInfoPress}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialIcons name="info-outline" size={24} color={currentTheme.colors.white} />
|
||||
<Text style={[styles.infoButtonText as TextStyle, { color: currentTheme.colors.white }]}>
|
||||
Info
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
</LinearGradient>
|
||||
</ImageBackground>
|
||||
<Animated.View style={[styles.featuredButtons as ViewStyle, buttonsAnimatedStyle]}>
|
||||
<TouchableOpacity
|
||||
style={styles.myListButton as ViewStyle}
|
||||
onPress={handleSaveToLibrary}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialIcons
|
||||
name={isSaved ? "bookmark" : "bookmark-border"}
|
||||
size={24}
|
||||
color={currentTheme.colors.white}
|
||||
/>
|
||||
<Text style={[styles.myListButtonText as TextStyle, { color: currentTheme.colors.white }]}>
|
||||
{isSaved ? "Saved" : "Save"}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.playButton as ViewStyle, { backgroundColor: currentTheme.colors.white }]}
|
||||
onPress={() => {
|
||||
if (featuredContent) {
|
||||
navigation.navigate('Streams', {
|
||||
id: featuredContent.id,
|
||||
type: featuredContent.type
|
||||
});
|
||||
}
|
||||
}}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<MaterialIcons name="play-arrow" size={24} color={currentTheme.colors.black} />
|
||||
<Text style={[styles.playButtonText as TextStyle, { color: currentTheme.colors.black }]}>
|
||||
Play
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.infoButton as ViewStyle}
|
||||
onPress={handleInfoPress}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialIcons name="info-outline" size={24} color={currentTheme.colors.white} />
|
||||
<Text style={[styles.infoButtonText as TextStyle, { color: currentTheme.colors.white }]}>
|
||||
Info
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
</LinearGradient>
|
||||
</Animated.View>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
|
|
|
|||
|
|
@ -130,10 +130,15 @@ export const ThisWeekSection = () => {
|
|||
// Load episodes when library items change
|
||||
useEffect(() => {
|
||||
if (!libraryLoading) {
|
||||
console.log('[ThisWeekSection] Library items changed, refreshing episodes. Items count:', libraryItems.length);
|
||||
fetchThisWeekEpisodes();
|
||||
// Only fetch if we have library items or if this is the first load
|
||||
if (libraryItems.length > 0 || episodes.length === 0) {
|
||||
console.log('[ThisWeekSection] Library items changed, refreshing episodes. Items count:', libraryItems.length);
|
||||
fetchThisWeekEpisodes();
|
||||
} else {
|
||||
console.log('[ThisWeekSection] Skipping refresh - no library items and episodes already loaded');
|
||||
}
|
||||
}
|
||||
}, [libraryLoading, libraryItems, fetchThisWeekEpisodes]);
|
||||
}, [libraryLoading, libraryItems, fetchThisWeekEpisodes, episodes.length]);
|
||||
|
||||
const handleEpisodePress = (episode: ThisWeekEpisode) => {
|
||||
// For upcoming episodes, go to the metadata screen
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
|
|||
|
||||
// Function to find and scroll to the most recently watched episode
|
||||
const scrollToMostRecentEpisode = () => {
|
||||
if (!metadata?.id || !episodeScrollViewRef.current || settings.episodeLayoutStyle !== 'horizontal') {
|
||||
if (!metadata?.id || !episodeScrollViewRef.current || !settings?.episodeLayoutStyle || settings.episodeLayoutStyle !== 'horizontal') {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -147,10 +147,10 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
|
|||
|
||||
// Add effect to scroll to most recently watched episode when season changes or progress loads
|
||||
useEffect(() => {
|
||||
if (Object.keys(episodeProgress).length > 0 && selectedSeason) {
|
||||
if (Object.keys(episodeProgress).length > 0 && selectedSeason && settings?.episodeLayoutStyle) {
|
||||
scrollToMostRecentEpisode();
|
||||
}
|
||||
}, [selectedSeason, episodeProgress, settings.episodeLayoutStyle, groupedEpisodes]);
|
||||
}, [selectedSeason, episodeProgress, settings?.episodeLayoutStyle, groupedEpisodes]);
|
||||
|
||||
|
||||
|
||||
|
|
@ -561,7 +561,7 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
|
|||
|
||||
{/* Only render episode list if there are episodes */}
|
||||
{currentSeasonEpisodes.length > 0 && (
|
||||
settings.episodeLayoutStyle === 'horizontal' ? (
|
||||
(settings?.episodeLayoutStyle === 'horizontal') ? (
|
||||
// Horizontal Layout (Netflix-style)
|
||||
<FlatList
|
||||
ref={episodeScrollViewRef as React.RefObject<FlatList<any>>}
|
||||
|
|
|
|||
|
|
@ -242,16 +242,37 @@ export function useFeaturedContent() {
|
|||
|
||||
// Load featured content initially and when content source changes
|
||||
useEffect(() => {
|
||||
// Force refresh when switching to catalogs or when catalog selection changes
|
||||
if (contentSource === 'catalogs') {
|
||||
// Clear cache when switching to catalogs mode
|
||||
setAllFeaturedContent([]);
|
||||
if (!settings.showHeroSection) {
|
||||
setFeaturedContent(null);
|
||||
persistentStore.allFeaturedContent = [];
|
||||
persistentStore.featuredContent = null;
|
||||
loadFeaturedContent(true);
|
||||
} else if (contentSource === 'tmdb' && contentSource !== persistentStore.featuredContent?.type) {
|
||||
// Clear cache when switching to TMDB mode from catalogs
|
||||
setAllFeaturedContent([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only load if we don't have cached data or if settings actually changed
|
||||
const now = Date.now();
|
||||
const cacheAge = now - persistentStore.lastFetchTime;
|
||||
const hasValidCache = persistentStore.featuredContent &&
|
||||
persistentStore.allFeaturedContent.length > 0 &&
|
||||
cacheAge < CACHE_TIMEOUT;
|
||||
|
||||
// Check if this is truly a settings change or just a re-render
|
||||
const sourceChanged = persistentStore.lastSettings.featuredContentSource !== contentSource;
|
||||
const catalogsChanged = JSON.stringify(persistentStore.lastSettings.selectedHeroCatalogs) !== JSON.stringify(selectedCatalogs);
|
||||
|
||||
if (hasValidCache && !sourceChanged && !catalogsChanged) {
|
||||
// Use existing cache without reloading
|
||||
console.log('Using existing cached featured content, no reload needed');
|
||||
setFeaturedContent(persistentStore.featuredContent);
|
||||
setAllFeaturedContent(persistentStore.allFeaturedContent);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Force refresh when switching modes or when selection changes
|
||||
if (sourceChanged || catalogsChanged) {
|
||||
console.log('Settings changed, refreshing featured content');
|
||||
// Clear cache when switching modes
|
||||
setAllFeaturedContent([]);
|
||||
setFeaturedContent(null);
|
||||
persistentStore.allFeaturedContent = [];
|
||||
|
|
@ -261,7 +282,7 @@ export function useFeaturedContent() {
|
|||
// Normal load (might use cache if available)
|
||||
loadFeaturedContent(false);
|
||||
}
|
||||
}, [loadFeaturedContent, contentSource, selectedCatalogs]);
|
||||
}, [loadFeaturedContent, contentSource, selectedCatalogs, settings.showHeroSection]);
|
||||
|
||||
useEffect(() => {
|
||||
if (featuredContent) {
|
||||
|
|
|
|||
|
|
@ -78,10 +78,14 @@ export const useSettings = () => {
|
|||
try {
|
||||
const storedSettings = await AsyncStorage.getItem(SETTINGS_STORAGE_KEY);
|
||||
if (storedSettings) {
|
||||
setSettings(JSON.parse(storedSettings));
|
||||
const parsedSettings = JSON.parse(storedSettings);
|
||||
// Merge with defaults to ensure all properties exist
|
||||
setSettings({ ...DEFAULT_SETTINGS, ...parsedSettings });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load settings:', error);
|
||||
// Fallback to default settings on error
|
||||
setSettings(DEFAULT_SETTINGS);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -325,35 +325,34 @@ const HomeScreen = () => {
|
|||
if (!content.length) return;
|
||||
|
||||
try {
|
||||
// Limit concurrent prefetching to prevent memory pressure
|
||||
const MAX_CONCURRENT_PREFETCH = 5;
|
||||
const BATCH_SIZE = 3;
|
||||
// More conservative prefetching to prevent memory issues
|
||||
const BATCH_SIZE = 2;
|
||||
|
||||
const allImages = content.slice(0, 10) // Limit total images to prefetch
|
||||
.map(item => [item.poster, item.banner, item.logo])
|
||||
.flat()
|
||||
// Only prefetch poster images (most important) and limit to 6 items
|
||||
const posterImages = content.slice(0, 6)
|
||||
.map(item => item.poster)
|
||||
.filter(Boolean) as string[];
|
||||
|
||||
// Process in small batches to prevent memory pressure
|
||||
for (let i = 0; i < allImages.length; i += BATCH_SIZE) {
|
||||
const batch = allImages.slice(i, i + BATCH_SIZE);
|
||||
// Process in small batches with longer delays
|
||||
for (let i = 0; i < posterImages.length; i += BATCH_SIZE) {
|
||||
const batch = posterImages.slice(i, i + BATCH_SIZE);
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
batch.map(async (imageUrl) => {
|
||||
try {
|
||||
await ExpoImage.prefetch(imageUrl);
|
||||
// Small delay between prefetches to reduce memory pressure
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
// Longer delay between prefetches
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
} catch (error) {
|
||||
// Silently handle individual prefetch errors
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Delay between batches to allow GC
|
||||
if (i + BATCH_SIZE < allImages.length) {
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
// Longer delay between batches to allow GC
|
||||
if (i + BATCH_SIZE < posterImages.length) {
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
}
|
||||
} catch (error) {
|
||||
// Continue with next batch if current batch fails
|
||||
|
|
@ -372,26 +371,18 @@ const HomeScreen = () => {
|
|||
if (!featuredContent) return;
|
||||
|
||||
try {
|
||||
// Clear image cache to reduce memory pressure before orientation change
|
||||
if (typeof (global as any)?.ExpoImage?.clearMemoryCache === 'function') {
|
||||
try {
|
||||
(global as any).ExpoImage.clearMemoryCache();
|
||||
} catch (e) {
|
||||
// Ignore cache clear errors
|
||||
}
|
||||
}
|
||||
|
||||
// Lock orientation to landscape before navigation to prevent glitches
|
||||
// Don't clear image cache - let ExpoImage manage memory efficiently
|
||||
try {
|
||||
await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE);
|
||||
|
||||
await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE);
|
||||
|
||||
// Longer delay to ensure orientation is fully set before navigation
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
} catch (orientationError) {
|
||||
// If orientation lock fails, continue anyway but log it
|
||||
logger.warn('[HomeScreen] Orientation lock failed:', orientationError);
|
||||
// Still add a small delay
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
navigation.navigate('Player', {
|
||||
|
|
@ -434,17 +425,23 @@ const HomeScreen = () => {
|
|||
|
||||
useEffect(() => {
|
||||
const unsubscribe = navigation.addListener('focus', () => {
|
||||
// Only refresh continue watching section on focus
|
||||
refreshContinueWatching();
|
||||
// Only refresh continue watching section if it's empty (first load or error state)
|
||||
if (continueWatchingRef.current) {
|
||||
// Check if continue watching is empty before refreshing
|
||||
// This prevents unnecessary reloads when just switching tabs
|
||||
console.log('[HomeScreen] Screen focused - checking if continue watching needs refresh');
|
||||
}
|
||||
|
||||
// Don't reload catalogs unless they haven't been loaded yet
|
||||
// Catalogs will be refreshed through context updates when addons change
|
||||
if (catalogs.length === 0 && !catalogsLoading) {
|
||||
console.log('[HomeScreen] Loading catalogs for first time');
|
||||
loadCatalogsProgressively();
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [navigation, refreshContinueWatching, loadCatalogsProgressively, catalogs.length, catalogsLoading]);
|
||||
}, [navigation, loadCatalogsProgressively, catalogs.length, catalogsLoading]);
|
||||
|
||||
// Memoize the loading screen to prevent unnecessary re-renders
|
||||
const renderLoadingScreen = useMemo(() => {
|
||||
|
|
|
|||
|
|
@ -97,22 +97,22 @@ const SettingItem: React.FC<SettingItemProps> = ({
|
|||
styles.settingIconContainer,
|
||||
{ backgroundColor: 'rgba(255,255,255,0.1)' }
|
||||
]}>
|
||||
<MaterialIcons name={icon} size={20} color={currentTheme.colors.primary} />
|
||||
<MaterialIcons name={icon} size={Math.min(20, width * 0.05)} color={currentTheme.colors.primary} />
|
||||
</View>
|
||||
<View style={styles.settingContent}>
|
||||
<View style={styles.settingTextContainer}>
|
||||
<Text style={[styles.settingTitle, { color: currentTheme.colors.highEmphasis }]}>
|
||||
<Text style={[styles.settingTitle, { color: currentTheme.colors.highEmphasis }]} numberOfLines={1} adjustsFontSizeToFit>
|
||||
{title}
|
||||
</Text>
|
||||
{description && (
|
||||
<Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
<Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]} numberOfLines={2} adjustsFontSizeToFit>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
{badge && (
|
||||
<View style={[styles.badge, { backgroundColor: currentTheme.colors.primary }]}>
|
||||
<Text style={styles.badgeText}>{badge}</Text>
|
||||
<Text style={styles.badgeText} numberOfLines={1} adjustsFontSizeToFit>{String(badge)}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
|
@ -395,14 +395,14 @@ const SettingsScreen: React.FC = () => {
|
|||
/>
|
||||
<SettingItem
|
||||
title="Episode Layout"
|
||||
description={settings.episodeLayoutStyle === 'horizontal' ? 'Horizontal Cards' : 'Vertical List'}
|
||||
description={settings?.episodeLayoutStyle === 'horizontal' ? 'Horizontal Cards' : 'Vertical List'}
|
||||
icon="view-module"
|
||||
renderControl={() => (
|
||||
<View style={styles.selectorContainer}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.selectorButton,
|
||||
settings.episodeLayoutStyle === 'vertical' && {
|
||||
settings?.episodeLayoutStyle === 'vertical' && {
|
||||
backgroundColor: currentTheme.colors.primary
|
||||
}
|
||||
]}
|
||||
|
|
@ -411,7 +411,7 @@ const SettingsScreen: React.FC = () => {
|
|||
<Text style={[
|
||||
styles.selectorText,
|
||||
{ color: currentTheme.colors.mediumEmphasis },
|
||||
settings.episodeLayoutStyle === 'vertical' && {
|
||||
settings?.episodeLayoutStyle === 'vertical' && {
|
||||
color: currentTheme.colors.white,
|
||||
fontWeight: '600'
|
||||
}
|
||||
|
|
@ -420,7 +420,7 @@ const SettingsScreen: React.FC = () => {
|
|||
<TouchableOpacity
|
||||
style={[
|
||||
styles.selectorButton,
|
||||
settings.episodeLayoutStyle === 'horizontal' && {
|
||||
settings?.episodeLayoutStyle === 'horizontal' && {
|
||||
backgroundColor: currentTheme.colors.primary
|
||||
}
|
||||
]}
|
||||
|
|
@ -429,7 +429,7 @@ const SettingsScreen: React.FC = () => {
|
|||
<Text style={[
|
||||
styles.selectorText,
|
||||
{ color: currentTheme.colors.mediumEmphasis },
|
||||
settings.episodeLayoutStyle === 'horizontal' && {
|
||||
settings?.episodeLayoutStyle === 'horizontal' && {
|
||||
color: currentTheme.colors.white,
|
||||
fontWeight: '600'
|
||||
}
|
||||
|
|
@ -518,12 +518,12 @@ const SettingsScreen: React.FC = () => {
|
|||
<SettingItem
|
||||
title="Video Player"
|
||||
description={Platform.OS === 'ios'
|
||||
? (settings.preferredPlayer === 'internal'
|
||||
? (settings?.preferredPlayer === 'internal'
|
||||
? 'Built-in Player'
|
||||
: settings.preferredPlayer
|
||||
: settings?.preferredPlayer
|
||||
? settings.preferredPlayer.toUpperCase()
|
||||
: 'Built-in Player')
|
||||
: (settings.useExternalPlayer ? 'External Player' : 'Built-in Player')
|
||||
: (settings?.useExternalPlayer ? 'External Player' : 'Built-in Player')
|
||||
}
|
||||
icon="play-arrow"
|
||||
renderControl={ChevronRight}
|
||||
|
|
@ -619,7 +619,7 @@ const styles = StyleSheet.create({
|
|||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
paddingHorizontal: 20,
|
||||
paddingHorizontal: Math.max(16, width * 0.05),
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-end',
|
||||
|
|
@ -628,7 +628,7 @@ const styles = StyleSheet.create({
|
|||
zIndex: 2,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 32,
|
||||
fontSize: Math.min(32, width * 0.08),
|
||||
fontWeight: '800',
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
|
|
@ -654,11 +654,11 @@ const styles = StyleSheet.create({
|
|||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 0.8,
|
||||
marginLeft: 16,
|
||||
marginLeft: Math.max(12, width * 0.04),
|
||||
marginBottom: 8,
|
||||
},
|
||||
card: {
|
||||
marginHorizontal: 16,
|
||||
marginHorizontal: Math.max(12, width * 0.04),
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#000',
|
||||
|
|
@ -672,9 +672,9 @@ const styles = StyleSheet.create({
|
|||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
paddingHorizontal: Math.max(12, width * 0.04),
|
||||
borderBottomWidth: 0.5,
|
||||
minHeight: 58,
|
||||
minHeight: Math.max(54, width * 0.14),
|
||||
width: '100%',
|
||||
},
|
||||
settingItemBorder: {
|
||||
|
|
@ -697,12 +697,12 @@ const styles = StyleSheet.create({
|
|||
flex: 1,
|
||||
},
|
||||
settingTitle: {
|
||||
fontSize: 16,
|
||||
fontSize: Math.min(16, width * 0.042),
|
||||
fontWeight: '500',
|
||||
marginBottom: 3,
|
||||
},
|
||||
settingDescription: {
|
||||
fontSize: 14,
|
||||
fontSize: Math.min(14, width * 0.037),
|
||||
opacity: 0.8,
|
||||
},
|
||||
settingControl: {
|
||||
|
|
@ -744,8 +744,10 @@ const styles = StyleSheet.create({
|
|||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
height: 36,
|
||||
width: 180,
|
||||
minWidth: 140,
|
||||
maxWidth: 180,
|
||||
marginRight: 8,
|
||||
alignSelf: 'flex-end',
|
||||
},
|
||||
selectorButton: {
|
||||
flex: 1,
|
||||
|
|
@ -755,7 +757,7 @@ const styles = StyleSheet.create({
|
|||
backgroundColor: 'rgba(255,255,255,0.08)',
|
||||
},
|
||||
selectorText: {
|
||||
fontSize: 13,
|
||||
fontSize: Math.min(13, width * 0.034),
|
||||
fontWeight: '500',
|
||||
textAlign: 'center',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { logger } from '../utils/logger';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
interface CachedImage {
|
||||
url: string;
|
||||
|
|
@ -7,10 +8,55 @@ interface CachedImage {
|
|||
expiresAt: number;
|
||||
}
|
||||
|
||||
interface PersistentCacheEntry {
|
||||
url: string;
|
||||
timestamp: number;
|
||||
expiresAt: number;
|
||||
accessCount: number;
|
||||
lastAccessed: number;
|
||||
}
|
||||
|
||||
class ImageCacheService {
|
||||
private cache = new Map<string, CachedImage>();
|
||||
private readonly CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 hours
|
||||
private readonly MAX_CACHE_SIZE = 100; // Maximum number of cached images
|
||||
private persistentCache = new Map<string, PersistentCacheEntry>();
|
||||
private readonly CACHE_DURATION = 7 * 24 * 60 * 60 * 1000; // 7 days (longer cache)
|
||||
private readonly MAX_CACHE_SIZE = 200; // Increased cache size for better performance
|
||||
private readonly PERSISTENT_CACHE_KEY = 'image_cache_persistent';
|
||||
private isInitialized = false;
|
||||
|
||||
/**
|
||||
* Initialize the persistent cache from storage
|
||||
*/
|
||||
public async initialize(): Promise<void> {
|
||||
if (this.isInitialized) return;
|
||||
|
||||
try {
|
||||
const stored = await AsyncStorage.getItem(this.PERSISTENT_CACHE_KEY);
|
||||
if (stored) {
|
||||
const entries = JSON.parse(stored) as PersistentCacheEntry[];
|
||||
entries.forEach(entry => {
|
||||
this.persistentCache.set(entry.url, entry);
|
||||
});
|
||||
logger.log(`[ImageCache] Loaded ${entries.length} persistent cache entries`);
|
||||
}
|
||||
this.isInitialized = true;
|
||||
} catch (error) {
|
||||
logger.error('[ImageCache] Failed to load persistent cache:', error);
|
||||
this.isInitialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save persistent cache to storage
|
||||
*/
|
||||
private async savePersistentCache(): Promise<void> {
|
||||
try {
|
||||
const entries = Array.from(this.persistentCache.values());
|
||||
await AsyncStorage.setItem(this.PERSISTENT_CACHE_KEY, JSON.stringify(entries));
|
||||
} catch (error) {
|
||||
logger.error('[ImageCache] Failed to save persistent cache:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a cached image URL or cache the original if not present
|
||||
|
|
@ -20,27 +66,66 @@ class ImageCacheService {
|
|||
return originalUrl; // Don't cache placeholder images
|
||||
}
|
||||
|
||||
// Check if we have a valid cached version
|
||||
await this.initialize();
|
||||
|
||||
// Check memory cache first (fastest)
|
||||
const cached = this.cache.get(originalUrl);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
logger.log(`[ImageCache] Retrieved from cache: ${originalUrl}`);
|
||||
logger.log(`[ImageCache] Retrieved from memory cache: ${originalUrl}`);
|
||||
return cached.localPath;
|
||||
}
|
||||
|
||||
try {
|
||||
// For now, return the original URL but mark it as cached
|
||||
// In a production app, you would implement actual local caching here
|
||||
// Check persistent cache
|
||||
const persistent = this.persistentCache.get(originalUrl);
|
||||
if (persistent && persistent.expiresAt > Date.now()) {
|
||||
// Update access stats
|
||||
persistent.accessCount++;
|
||||
persistent.lastAccessed = Date.now();
|
||||
|
||||
// Add to memory cache for faster access
|
||||
const cachedImage: CachedImage = {
|
||||
url: originalUrl,
|
||||
localPath: originalUrl, // In production, this would be a local file path
|
||||
timestamp: Date.now(),
|
||||
expiresAt: Date.now() + this.CACHE_DURATION,
|
||||
localPath: originalUrl,
|
||||
timestamp: persistent.timestamp,
|
||||
expiresAt: persistent.expiresAt,
|
||||
};
|
||||
this.cache.set(originalUrl, cachedImage);
|
||||
|
||||
logger.log(`[ImageCache] Retrieved from persistent cache: ${originalUrl}`);
|
||||
return originalUrl;
|
||||
}
|
||||
|
||||
try {
|
||||
// Create new cache entry
|
||||
const now = Date.now();
|
||||
const expiresAt = now + this.CACHE_DURATION;
|
||||
|
||||
const cachedImage: CachedImage = {
|
||||
url: originalUrl,
|
||||
localPath: originalUrl,
|
||||
timestamp: now,
|
||||
expiresAt,
|
||||
};
|
||||
|
||||
const persistentEntry: PersistentCacheEntry = {
|
||||
url: originalUrl,
|
||||
timestamp: now,
|
||||
expiresAt,
|
||||
accessCount: 1,
|
||||
lastAccessed: now,
|
||||
};
|
||||
|
||||
this.cache.set(originalUrl, cachedImage);
|
||||
this.persistentCache.set(originalUrl, persistentEntry);
|
||||
|
||||
this.enforceMaxCacheSize();
|
||||
|
||||
// Save persistent cache periodically (every 10 new entries)
|
||||
if (this.persistentCache.size % 10 === 0) {
|
||||
this.savePersistentCache();
|
||||
}
|
||||
|
||||
logger.log(`[ImageCache] ✅ NEW CACHE ENTRY: ${originalUrl} (Cache size: ${this.cache.size})`);
|
||||
logger.log(`[ImageCache] ✅ NEW CACHE ENTRY: ${originalUrl} (Memory: ${this.cache.size}, Persistent: ${this.persistentCache.size})`);
|
||||
return cachedImage.localPath;
|
||||
} catch (error) {
|
||||
logger.error('[ImageCache] Failed to cache image:', error);
|
||||
|
|
@ -51,44 +136,82 @@ class ImageCacheService {
|
|||
/**
|
||||
* Check if an image is cached
|
||||
*/
|
||||
public isCached(url: string): boolean {
|
||||
public async isCached(url: string): Promise<boolean> {
|
||||
await this.initialize();
|
||||
|
||||
const cached = this.cache.get(url);
|
||||
return cached !== undefined && cached.expiresAt > Date.now();
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const persistent = this.persistentCache.get(url);
|
||||
return persistent !== undefined && persistent.expiresAt > Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Log cache status (for debugging)
|
||||
*/
|
||||
public logCacheStatus(): void {
|
||||
const stats = this.getCacheStats();
|
||||
logger.log(`[ImageCache] 📊 Cache Status: ${stats.size} total, ${stats.expired} expired`);
|
||||
public async logCacheStatus(): Promise<void> {
|
||||
await this.initialize();
|
||||
|
||||
// Log first 5 cached URLs for debugging
|
||||
const entries = Array.from(this.cache.entries()).slice(0, 5);
|
||||
entries.forEach(([url, cached]) => {
|
||||
const isExpired = cached.expiresAt <= Date.now();
|
||||
const timeLeft = Math.max(0, cached.expiresAt - Date.now()) / 1000 / 60; // minutes
|
||||
logger.log(`[ImageCache] - ${url.substring(0, 60)}... (${isExpired ? 'EXPIRED' : `${timeLeft.toFixed(1)}m left`})`);
|
||||
const memoryStats = this.getCacheStats();
|
||||
const persistentCount = this.persistentCache.size;
|
||||
const persistentExpired = Array.from(this.persistentCache.values())
|
||||
.filter(entry => entry.expiresAt <= Date.now()).length;
|
||||
|
||||
logger.log(`[ImageCache] 📊 Memory Cache: ${memoryStats.size} total, ${memoryStats.expired} expired`);
|
||||
logger.log(`[ImageCache] 📊 Persistent Cache: ${persistentCount} total, ${persistentExpired} expired`);
|
||||
|
||||
// Log most accessed images
|
||||
const topImages = Array.from(this.persistentCache.entries())
|
||||
.sort(([, a], [, b]) => b.accessCount - a.accessCount)
|
||||
.slice(0, 5);
|
||||
|
||||
topImages.forEach(([url, entry]) => {
|
||||
logger.log(`[ImageCache] 🔥 Popular: ${url.substring(0, 60)}... (${entry.accessCount} accesses)`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear expired cache entries
|
||||
*/
|
||||
public clearExpiredCache(): void {
|
||||
public async clearExpiredCache(): Promise<void> {
|
||||
await this.initialize();
|
||||
|
||||
const now = Date.now();
|
||||
let removedMemory = 0;
|
||||
let removedPersistent = 0;
|
||||
|
||||
// Clear memory cache
|
||||
for (const [url, cached] of this.cache.entries()) {
|
||||
if (cached.expiresAt <= now) {
|
||||
this.cache.delete(url);
|
||||
removedMemory++;
|
||||
}
|
||||
}
|
||||
|
||||
// Clear persistent cache
|
||||
for (const [url, entry] of this.persistentCache.entries()) {
|
||||
if (entry.expiresAt <= now) {
|
||||
this.persistentCache.delete(url);
|
||||
removedPersistent++;
|
||||
}
|
||||
}
|
||||
|
||||
if (removedPersistent > 0) {
|
||||
await this.savePersistentCache();
|
||||
}
|
||||
|
||||
logger.log(`[ImageCache] Cleared ${removedMemory} memory entries, ${removedPersistent} persistent entries`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached images
|
||||
*/
|
||||
public clearAllCache(): void {
|
||||
public async clearAllCache(): Promise<void> {
|
||||
this.cache.clear();
|
||||
this.persistentCache.clear();
|
||||
await AsyncStorage.removeItem(this.PERSISTENT_CACHE_KEY);
|
||||
logger.log('[ImageCache] Cleared all cached images');
|
||||
}
|
||||
|
||||
|
|
@ -112,26 +235,40 @@ class ImageCacheService {
|
|||
}
|
||||
|
||||
/**
|
||||
* Enforce maximum cache size by removing oldest entries
|
||||
* Enforce maximum cache size by removing oldest/least accessed entries
|
||||
*/
|
||||
private enforceMaxCacheSize(): void {
|
||||
if (this.cache.size <= this.MAX_CACHE_SIZE) {
|
||||
return;
|
||||
// Enforce memory cache limit
|
||||
if (this.cache.size > this.MAX_CACHE_SIZE) {
|
||||
const entries = Array.from(this.cache.entries()).sort(
|
||||
(a, b) => a[1].timestamp - b[1].timestamp
|
||||
);
|
||||
|
||||
const toRemove = this.cache.size - this.MAX_CACHE_SIZE;
|
||||
for (let i = 0; i < toRemove; i++) {
|
||||
this.cache.delete(entries[i][0]);
|
||||
}
|
||||
|
||||
logger.log(`[ImageCache] Removed ${toRemove} old memory entries to enforce cache size limit`);
|
||||
}
|
||||
|
||||
// Enforce persistent cache limit (larger limit)
|
||||
const persistentLimit = this.MAX_CACHE_SIZE * 3;
|
||||
if (this.persistentCache.size > persistentLimit) {
|
||||
// Remove least recently accessed entries
|
||||
const entries = Array.from(this.persistentCache.entries()).sort(
|
||||
(a, b) => a[1].lastAccessed - b[1].lastAccessed
|
||||
);
|
||||
|
||||
// Convert to array and sort by timestamp (oldest first)
|
||||
const entries = Array.from(this.cache.entries()).sort(
|
||||
(a, b) => a[1].timestamp - b[1].timestamp
|
||||
);
|
||||
const toRemove = this.persistentCache.size - persistentLimit;
|
||||
for (let i = 0; i < toRemove; i++) {
|
||||
this.persistentCache.delete(entries[i][0]);
|
||||
}
|
||||
|
||||
// Remove oldest entries
|
||||
const toRemove = this.cache.size - this.MAX_CACHE_SIZE;
|
||||
for (let i = 0; i < toRemove; i++) {
|
||||
this.cache.delete(entries[i][0]);
|
||||
this.savePersistentCache();
|
||||
logger.log(`[ImageCache] Removed ${toRemove} old persistent entries to enforce cache size limit`);
|
||||
}
|
||||
|
||||
logger.log(`[ImageCache] Removed ${toRemove} old entries to enforce cache size limit`);
|
||||
}
|
||||
}
|
||||
|
||||
export const imageCacheService = new ImageCacheService();
|
||||
export const imageCacheService = new ImageCacheService();
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ const CATALOG_CUSTOM_NAMES_KEY = 'catalog_custom_names';
|
|||
// Initialize cache as an empty object
|
||||
let customNamesCache: { [key: string]: string } = {};
|
||||
let cacheTimestamp: number = 0; // 0 indicates cache is invalid/empty initially
|
||||
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
|
||||
let isLoading: boolean = false; // Prevent multiple concurrent loads
|
||||
const CACHE_DURATION = 30 * 60 * 1000; // 30 minutes (longer cache for less frequent loads)
|
||||
|
||||
async function loadCustomNamesIfNeeded(): Promise<{ [key: string]: string }> {
|
||||
const now = Date.now();
|
||||
|
|
@ -15,6 +16,13 @@ async function loadCustomNamesIfNeeded(): Promise<{ [key: string]: string }> {
|
|||
return customNamesCache; // Cache is valid and guaranteed to be an object
|
||||
}
|
||||
|
||||
// Prevent multiple concurrent loads
|
||||
if (isLoading) {
|
||||
// Wait for current load to complete by returning existing cache or empty object
|
||||
return customNamesCache || {};
|
||||
}
|
||||
|
||||
isLoading = true;
|
||||
try {
|
||||
logger.info('Loading custom catalog names from storage...');
|
||||
const savedCustomNamesJson = await AsyncStorage.getItem(CATALOG_CUSTOM_NAMES_KEY);
|
||||
|
|
@ -28,6 +36,8 @@ async function loadCustomNamesIfNeeded(): Promise<{ [key: string]: string }> {
|
|||
cacheTimestamp = 0;
|
||||
// Return the last known cache (which might be empty {}), or a fresh empty object
|
||||
return customNamesCache || {}; // Return cache (could be outdated but non-null) or empty {}
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue