improved homescreen using flashlist

This commit is contained in:
tapframe 2025-08-09 17:25:51 +05:30
parent 2feec37eb3
commit a48cc0f2be
8 changed files with 209 additions and 190 deletions

1
.gitignore vendored
View file

@ -44,3 +44,4 @@ HEATING_OPTIMIZATIONS.md
ios
android
sliderreadme.md
.cursor/mcp.json

View file

@ -65,6 +65,7 @@ const OptimizedImage: React.FC<OptimizedImageProps> = ({
const [isLoaded, setIsLoaded] = useState(false);
const [hasError, setHasError] = useState(false);
const [isVisible, setIsVisible] = useState(!lazy);
const [recyclingKey] = useState(() => `${Math.random().toString(36).slice(2)}-${Date.now()}`);
const [optimizedUrl, setOptimizedUrl] = useState<string>('');
const mountedRef = useRef(true);
const loadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
@ -168,12 +169,15 @@ const OptimizedImage: React.FC<OptimizedImageProps> = ({
}
return (
<ExpoImage
<ExpoImage
source={{ uri: optimizedUrl }}
style={style}
contentFit={contentFit}
transition={transition}
cachePolicy={cachePolicy}
// Use a stable recycling key per component instance to keep textures alive between reuses
// This mitigates flicker on fast horizontal scrolls
recyclingKey={recyclingKey}
onLoad={() => {
setIsLoaded(true);
onLoad?.();

View file

@ -1,9 +1,9 @@
import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity, FlatList, Platform, Dimensions } from 'react-native';
import React, { useCallback } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Platform, Dimensions } from 'react-native';
import { FlashList } from '@shopify/flash-list';
import { NavigationProp, useNavigation } from '@react-navigation/native';
import { MaterialIcons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import Animated, { FadeIn } from 'react-native-reanimated';
import { CatalogContent, StreamingContent } from '../../services/catalogService';
import { useTheme } from '../../contexts/ThemeContext';
import ContentItem from './ContentItem';
@ -56,28 +56,27 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { currentTheme } = useTheme();
const handleContentPress = (id: string, type: string) => {
const handleContentPress = useCallback((id: string, type: string) => {
navigation.navigate('Metadata', { id, type, addonId: catalog.addon });
};
}, [navigation, catalog.addon]);
const renderContentItem = ({ item, index }: { item: StreamingContent, index: number }) => {
const renderContentItem = useCallback(({ item }: { item: StreamingContent, index: number }) => {
return (
<Animated.View
entering={FadeIn.duration(300).delay(100 + (index * 40))}
>
<ContentItem
item={item}
onPress={handleContentPress}
/>
</Animated.View>
<ContentItem
item={item}
onPress={handleContentPress}
/>
);
};
}, [handleContentPress]);
// Memoize the ItemSeparatorComponent to prevent re-creation
const ItemSeparator = useCallback(() => <View style={{ width: 8 }} />, []);
// Memoize the keyExtractor to prevent re-creation
const keyExtractor = useCallback((item: StreamingContent) => `${item.id}-${item.type}`, []);
return (
<Animated.View
style={styles.catalogContainer}
entering={FadeIn.duration(300).delay(50)}
>
<View style={styles.catalogContainer}>
<View style={styles.catalogHeader}>
<View style={styles.titleContainer}>
<Text style={[styles.catalogTitle, { color: currentTheme.colors.text }]} numberOfLines={1}>{catalog.name}</Text>
@ -98,34 +97,19 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => {
</TouchableOpacity>
</View>
<FlatList
<FlashList
data={catalog.items}
renderItem={renderContentItem}
keyExtractor={(item) => `${item.id}-${item.type}`}
keyExtractor={keyExtractor}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={[styles.catalogList, { paddingRight: 16 - posterLayout.partialPosterWidth }]}
snapToInterval={POSTER_WIDTH + 8}
decelerationRate="fast"
snapToAlignment="start"
ItemSeparatorComponent={() => <View style={{ width: 8 }} />}
initialNumToRender={4}
maxToRenderPerBatch={2}
windowSize={3}
removeClippedSubviews={Platform.OS === 'android'}
updateCellsBatchingPeriod={50}
getItemLayout={(data, index) => ({
length: POSTER_WIDTH + 8,
offset: (POSTER_WIDTH + 8) * index,
index,
})}
maintainVisibleContentPosition={{
minIndexForVisible: 0
}}
onEndReachedThreshold={0.5}
ItemSeparatorComponent={ItemSeparator}
onEndReachedThreshold={0.7}
onEndReached={() => {}}
scrollEventThrottle={16}
/>
</Animated.View>
</View>
);
};
@ -178,4 +162,18 @@ const styles = StyleSheet.create({
},
});
export default React.memo(CatalogSection);
export default React.memo(CatalogSection, (prevProps, nextProps) => {
// Only re-render if the catalog data actually changes
return (
prevProps.catalog.addon === nextProps.catalog.addon &&
prevProps.catalog.id === nextProps.catalog.id &&
prevProps.catalog.name === nextProps.catalog.name &&
prevProps.catalog.items.length === nextProps.catalog.items.length &&
// Deep compare the first few items to detect changes
prevProps.catalog.items.slice(0, 3).every((item, index) =>
nextProps.catalog.items[index] &&
item.id === nextProps.catalog.items[index].id &&
item.poster === nextProps.catalog.items[index].poster
)
);
});

View file

@ -50,13 +50,19 @@ const calculatePosterLayout = (screenWidth: number) => {
const posterLayout = calculatePosterLayout(width);
const POSTER_WIDTH = posterLayout.posterWidth;
const ContentItem = React.memo(({ item, onPress }: ContentItemProps) => {
const PLACEHOLDER_BLURHASH = 'LEHV6nWB2yk8pyo0adR*.7kCMdnj';
const ContentItem = ({ item, onPress }: ContentItemProps) => {
const [menuVisible, setMenuVisible] = useState(false);
const [isWatched, setIsWatched] = useState(false);
const [imageLoaded, setImageLoaded] = useState(false);
const [imageError, setImageError] = useState(false);
const [shouldLoadImage, setShouldLoadImage] = useState(false);
const { currentTheme } = useTheme();
// Intersection observer simulation for lazy loading
const itemRef = useRef<View>(null);
const handleLongPress = useCallback(() => {
setMenuVisible(true);
}, []);
@ -88,6 +94,30 @@ const ContentItem = React.memo(({ item, onPress }: ContentItemProps) => {
setMenuVisible(false);
}, []);
// Lazy load images - only load when likely to be visible
useEffect(() => {
const timer = setTimeout(() => {
setShouldLoadImage(true);
}, 100); // Small delay to avoid loading offscreen items
return () => clearTimeout(timer);
}, []);
// Get optimized poster URL for smaller tiles
const getOptimizedPosterUrl = useCallback((originalUrl: string) => {
if (!originalUrl) return 'https://via.placeholder.com/154x231/333/666?text=No+Image';
// For TMDB images, use smaller sizes
if (originalUrl.includes('image.tmdb.org')) {
// Replace any size with w154 (fits 100-130px tiles perfectly)
return originalUrl.replace(/\/w\d+\//, '/w154/');
}
// For other sources, try to add size parameters
const separator = originalUrl.includes('?') ? '&' : '?';
return `${originalUrl}${separator}w=154&h=231&q=75`;
}, []);
return (
<>
<View style={styles.itemContainer}>
@ -98,26 +128,36 @@ const ContentItem = React.memo(({ item, onPress }: ContentItemProps) => {
onLongPress={handleLongPress}
delayLongPress={300}
>
<View style={styles.contentItemContainer}>
<ExpoImage
source={{ uri: item.poster || 'https://via.placeholder.com/300x450' }}
style={styles.poster}
contentFit="cover"
cachePolicy="memory-disk"
transition={200}
placeholder={{ uri: 'https://via.placeholder.com/300x450' }}
placeholderContentFit="cover"
recyclingKey={item.id}
onLoad={() => {
setImageLoaded(true);
setImageError(false);
}}
onError={() => {
setImageError(true);
setImageLoaded(false);
}}
priority="low"
/>
<View ref={itemRef} style={styles.contentItemContainer}>
{/* Only load image when shouldLoadImage is true (lazy loading) */}
{shouldLoadImage && item.poster ? (
<ExpoImage
source={{ uri: getOptimizedPosterUrl(item.poster) }}
style={[styles.poster, { backgroundColor: currentTheme.colors.elevation1 }]}
contentFit="cover"
cachePolicy="disk" // Disk-only cache to save RAM
transition={0}
placeholder={{ blurhash: PLACEHOLDER_BLURHASH } as any}
placeholderContentFit="cover"
allowDownscaling
onLoad={() => {
setImageLoaded(true);
setImageError(false);
}}
onError={() => {
setImageError(true);
setImageLoaded(false);
}}
priority="low"
/>
) : (
// Show placeholder until lazy load triggers
<View style={[styles.poster, { backgroundColor: currentTheme.colors.elevation1, justifyContent: 'center', alignItems: 'center' }]}>
<Text style={{ color: currentTheme.colors.textMuted, fontSize: 10, textAlign: 'center' }}>
{item.name.substring(0, 20)}...
</Text>
</View>
)}
{imageError && (
<View style={[styles.loadingOverlay, { backgroundColor: currentTheme.colors.elevation1 }]}>
<MaterialIcons name="broken-image" size={24} color={currentTheme.colors.textMuted} />
@ -148,7 +188,7 @@ const ContentItem = React.memo(({ item, onPress }: ContentItemProps) => {
/>
</>
);
});
};
const styles = StyleSheet.create({
itemContainer: {
@ -158,14 +198,14 @@ const styles = StyleSheet.create({
width: POSTER_WIDTH,
aspectRatio: 2/3,
margin: 0,
borderRadius: 4,
borderRadius: 12,
overflow: 'hidden',
position: 'relative',
elevation: 6,
elevation: Platform.OS === 'android' ? 2 : 0,
shadowColor: '#000',
shadowOffset: { width: 0, height: 3 },
shadowOpacity: 0.25,
shadowRadius: 6,
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 2,
borderWidth: 0.5,
borderColor: 'rgba(255,255,255,0.12)',
marginBottom: 8,
@ -173,14 +213,14 @@ const styles = StyleSheet.create({
contentItemContainer: {
width: '100%',
height: '100%',
borderRadius: 4,
borderRadius: 12,
overflow: 'hidden',
position: 'relative',
},
poster: {
width: '100%',
height: '100%',
borderRadius: 4,
borderRadius: 12,
},
loadingOverlay: {
position: 'absolute',
@ -214,4 +254,8 @@ const styles = StyleSheet.create({
}
});
export default ContentItem;
export default React.memo(ContentItem, (prev, next) => {
// Aggressive memoization - only re-render if ID changes (different item entirely)
// This keeps loaded posters stable during fast scrolls
return prev.item.id === next.item.id;
});

View file

@ -3,7 +3,6 @@ import {
View,
Text,
StyleSheet,
FlatList,
TouchableOpacity,
Dimensions,
AppState,
@ -11,7 +10,8 @@ import {
Alert,
ActivityIndicator
} from 'react-native';
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
import { FlashList } from '@shopify/flash-list';
import Animated from 'react-native-reanimated';
import { useNavigation } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native';
import { RootStackParamList } from '../../navigation/AppNavigator';
@ -568,7 +568,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
}
return (
<Animated.View entering={FadeIn.duration(300).delay(150)} style={styles.container}>
<View style={styles.container}>
<View style={styles.header}>
<View style={styles.titleContainer}>
<Text style={[styles.title, { color: currentTheme.colors.text }]}>Continue Watching</Text>
@ -576,7 +576,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
</View>
</View>
<FlatList
<FlashList
data={continueWatchingItems}
renderItem={({ item }) => (
<TouchableOpacity
@ -597,7 +597,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
style={styles.continueWatchingPoster}
contentFit="cover"
cachePolicy="memory"
transition={200}
transition={0}
placeholder={{ uri: 'https://via.placeholder.com/300x450' }}
placeholderContentFit="cover"
recyclingKey={item.id}
@ -605,13 +605,9 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
{/* Delete Indicator Overlay */}
{deletingItemId === item.id && (
<Animated.View
entering={FadeIn.duration(200)}
exiting={FadeOut.duration(200)}
style={styles.deletingOverlay}
>
<View style={styles.deletingOverlay}>
<ActivityIndicator size="large" color="#FFFFFF" />
</Animated.View>
</View>
)}
</View>
@ -691,12 +687,11 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.wideList}
snapToInterval={280 + 16} // Card width + margin
decelerationRate="fast"
snapToAlignment="start"
ItemSeparatorComponent={() => <View style={{ width: 16 }} />}
onEndReachedThreshold={0.7}
onEndReached={() => {}}
/>
</Animated.View>
</View>
);
});
@ -740,7 +735,7 @@ const styles = StyleSheet.create({
width: 280,
height: 120,
flexDirection: 'row',
borderRadius: 12,
borderRadius: 14,
overflow: 'hidden',
elevation: 6,
shadowOffset: { width: 0, height: 3 },
@ -756,8 +751,8 @@ const styles = StyleSheet.create({
continueWatchingPoster: {
width: '100%',
height: '100%',
borderTopLeftRadius: 12,
borderBottomLeftRadius: 12,
borderTopLeftRadius: 14,
borderBottomLeftRadius: 14,
},
deletingOverlay: {
position: 'absolute',

View file

@ -20,7 +20,7 @@ import { tmdbService } from '../../services/tmdbService';
import { useLibrary } from '../../hooks/useLibrary';
import { RootStackParamList } from '../../navigation/AppNavigator';
import { parseISO, isThisWeek, format, isAfter, isBefore } from 'date-fns';
import Animated, { FadeIn, FadeInRight } from 'react-native-reanimated';
import Animated from 'react-native-reanimated';
import { useCalendarData } from '../../hooks/useCalendarData';
const { width } = Dimensions.get('window');
@ -109,10 +109,7 @@ export const ThisWeekSection = React.memo(() => {
item.poster);
return (
<Animated.View
entering={FadeInRight.delay(index * 50).duration(300)}
style={styles.episodeItemContainer}
>
<View style={styles.episodeItemContainer}>
<TouchableOpacity
style={[
styles.episodeItem,
@ -129,7 +126,7 @@ export const ThisWeekSection = React.memo(() => {
source={{ uri: imageUrl }}
style={styles.poster}
contentFit="cover"
transition={400}
transition={0}
/>
{/* Enhanced gradient overlay */}
@ -177,12 +174,12 @@ export const ThisWeekSection = React.memo(() => {
</LinearGradient>
</View>
</TouchableOpacity>
</Animated.View>
</View>
);
};
return (
<Animated.View entering={FadeIn.duration(300)} style={styles.container}>
<View style={styles.container}>
<View style={styles.header}>
<View style={styles.titleContainer}>
<Text style={[styles.title, { color: currentTheme.colors.text }]}>This Week</Text>
@ -206,7 +203,7 @@ export const ThisWeekSection = React.memo(() => {
snapToAlignment="start"
ItemSeparatorComponent={() => <View style={{ width: 16 }} />}
/>
</Animated.View>
</View>
);
});

View file

@ -3,7 +3,6 @@ import {
View,
Text,
StyleSheet,
FlatList,
TouchableOpacity,
ActivityIndicator,
SafeAreaView,
@ -18,6 +17,7 @@ import {
Pressable,
Alert
} from 'react-native';
import { FlashList } from '@shopify/flash-list';
import { useNavigation, useFocusEffect } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native';
import { RootStackParamList } from '../navigation/AppNavigator';
@ -27,18 +27,7 @@ import { Stream } from '../types/metadata';
import { MaterialIcons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { Image as ExpoImage } from 'expo-image';
import Animated, {
FadeIn,
FadeOut,
useAnimatedStyle,
withSpring,
withTiming,
useSharedValue,
interpolate,
Extrapolate,
runOnJS,
useAnimatedGestureHandler,
} from 'react-native-reanimated';
import Animated, { FadeIn } from 'react-native-reanimated';
import { PanGestureHandler } from 'react-native-gesture-handler';
import {
Gesture,
@ -126,7 +115,7 @@ const HomeScreen = () => {
const [hasAddons, setHasAddons] = useState<boolean | null>(null);
const [hintVisible, setHintVisible] = useState(false);
const totalCatalogsRef = useRef(0);
const [visibleCatalogCount, setVisibleCatalogCount] = useState(8); // Moderate number of visible catalogs
const [visibleCatalogCount, setVisibleCatalogCount] = useState(5); // Reduced for memory
const insets = useSafeAreaInsets();
const {
@ -199,7 +188,7 @@ const HomeScreen = () => {
const metas = await stremioService.getCatalog(manifest, catalog.type, catalog.id, 1);
if (metas && metas.length > 0) {
// Limit items per catalog to reduce memory usage
const limitedMetas = metas.slice(0, 20); // Moderate limit for better content variety
const limitedMetas = metas.slice(0, 8); // Further reduced for memory
const items = limitedMetas.map((meta: any) => ({
id: meta.id,
@ -218,6 +207,8 @@ const HomeScreen = () => {
creators: meta.creator,
certification: meta.certification
}));
// Skip prefetching to reduce memory pressure
let displayName = catalog.name;
const contentType = catalog.type === 'movie' ? 'Movies' : 'TV Shows';
@ -398,20 +389,8 @@ const HomeScreen = () => {
};
}, [currentTheme.colors.darkBackground]);
// Periodic memory cleanup when many catalogs are loaded
useEffect(() => {
if (catalogs.filter(c => c).length > 15) {
const cleanup = setTimeout(() => {
try {
ExpoImage.clearMemoryCache();
} catch (error) {
console.warn('Failed to clear image cache:', error);
}
}, 60000); // Clean every 60 seconds when many catalogs are loaded
return () => clearTimeout(cleanup);
}
}, [catalogs]);
// Removed periodic forced cache clearing to avoid churn under load
// useEffect(() => {}, [catalogs]);
// Balanced preload images function
const preloadImages = useCallback(async (content: StreamingContent[]) => {
@ -567,10 +546,7 @@ const HomeScreen = () => {
return data;
}
// Normal flow when addons are present
if (showHeroSection) {
data.push({ type: 'featured', key: 'featured' });
}
// Normal flow when addons are present (featured moved to ListHeaderComponent)
data.push({ type: 'thisWeek', key: 'thisWeek' });
data.push({ type: 'continueWatching', key: 'continueWatching' });
@ -596,51 +572,64 @@ const HomeScreen = () => {
}, [hasAddons, showHeroSection, catalogs, visibleCatalogCount]);
const handleLoadMoreCatalogs = useCallback(() => {
setVisibleCatalogCount(prev => Math.min(prev + 5, catalogs.length));
setVisibleCatalogCount(prev => Math.min(prev + 3, catalogs.length));
}, [catalogs.length]);
// Add memory cleanup on scroll end
const handleScrollEnd = useCallback(() => {
// Clear memory cache after scroll settles to free up RAM
setTimeout(() => {
try {
ExpoImage.clearMemoryCache();
} catch (error) {
// Ignore errors
}
}, 1000);
}, []);
// Memoize individual section components to prevent re-renders
const memoizedFeaturedContent = useMemo(() => (
<FeaturedContent
key={`featured-${showHeroSection}-${featuredContentSource}`}
featuredContent={featuredContent}
isSaved={isSaved}
handleSaveToLibrary={handleSaveToLibrary}
/>
), [showHeroSection, featuredContentSource, featuredContent, isSaved, handleSaveToLibrary]);
const memoizedThisWeekSection = useMemo(() => <ThisWeekSection />, []);
const memoizedContinueWatchingSection = useMemo(() => <ContinueWatchingSection ref={continueWatchingRef} />, []);
const renderListItem = useCallback(({ item }: { item: HomeScreenListItem }) => {
switch (item.type) {
case 'featured':
return (
<FeaturedContent
key={`featured-${showHeroSection}-${featuredContentSource}`}
featuredContent={featuredContent}
isSaved={isSaved}
handleSaveToLibrary={handleSaveToLibrary}
/>
);
// featured is rendered via ListHeaderComponent to avoid remounts
case 'thisWeek':
return <Animated.View entering={FadeIn.duration(300).delay(100)}><ThisWeekSection /></Animated.View>;
return memoizedThisWeekSection;
case 'continueWatching':
return <ContinueWatchingSection ref={continueWatchingRef} />;
return memoizedContinueWatchingSection;
case 'catalog':
return (
<Animated.View entering={FadeIn.duration(300)}>
<CatalogSection catalog={item.catalog} />
</Animated.View>
<CatalogSection catalog={item.catalog} />
);
case 'placeholder':
return (
<Animated.View entering={FadeIn.duration(300)}>
<View style={styles.catalogPlaceholder}>
<View style={styles.placeholderHeader}>
<View style={[styles.placeholderTitle, { backgroundColor: currentTheme.colors.elevation1 }]} />
<ActivityIndicator size="small" color={currentTheme.colors.primary} />
</View>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.placeholderPosters}>
{[...Array(3)].map((_, posterIndex) => (
<View
key={posterIndex}
style={[styles.placeholderPoster, { backgroundColor: currentTheme.colors.elevation1 }]}
/>
))}
</ScrollView>
<View style={styles.catalogPlaceholder}>
<View style={styles.placeholderHeader}>
<View style={[styles.placeholderTitle, { backgroundColor: currentTheme.colors.elevation1 }]} />
<ActivityIndicator size="small" color={currentTheme.colors.primary} />
</View>
</Animated.View>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.placeholderPosters}>
{[...Array(3)].map((_, posterIndex) => (
<View
key={posterIndex}
style={[styles.placeholderPoster, { backgroundColor: currentTheme.colors.elevation1 }]}
/>
))}
</ScrollView>
</View>
);
case 'loadMore':
return (
@ -673,16 +662,11 @@ const HomeScreen = () => {
handleLoadMoreCatalogs
]);
// FlashList: using minimal props per installed version
const ListFooterComponent = useMemo(() => (
<>
{catalogsLoading && loadedCatalogCount > 0 && loadedCatalogCount < totalCatalogsRef.current && (
<View style={styles.loadingMoreCatalogs}>
<ActivityIndicator size="small" color={currentTheme.colors.primary} />
<Text style={[styles.loadingMoreText, { color: currentTheme.colors.textMuted }]}>
Loading catalogs... ({loadedCatalogCount}/{totalCatalogsRef.current})
</Text>
</View>
)}
{catalogsLoading && loadedCatalogCount > 0 && loadedCatalogCount < totalCatalogsRef.current && null}
{!catalogsLoading && catalogs.filter(c => c).length === 0 && (
<View style={[styles.emptyCatalog, { backgroundColor: currentTheme.colors.elevation1 }]}>
<MaterialIcons name="movie-filter" size={40} color={currentTheme.colors.textDark} />
@ -712,7 +696,7 @@ const HomeScreen = () => {
backgroundColor="transparent"
translucent
/>
<FlatList
<FlashList
data={listData}
renderItem={renderListItem}
keyExtractor={item => item.key}
@ -721,19 +705,12 @@ const HomeScreen = () => {
{ paddingTop: Platform.OS === 'ios' ? 100 : 90 }
]}
showsVerticalScrollIndicator={false}
ListHeaderComponent={showHeroSection ? memoizedFeaturedContent : null}
ListFooterComponent={ListFooterComponent}
initialNumToRender={4}
maxToRenderPerBatch={3}
windowSize={7}
removeClippedSubviews={Platform.OS === 'android'}
onEndReachedThreshold={0.5}
updateCellsBatchingPeriod={50}
maintainVisibleContentPosition={{
minIndexForVisible: 0,
autoscrollToTopThreshold: 10
}}
disableIntervalMomentum={true}
scrollEventThrottle={16}
onMomentumScrollEnd={handleScrollEnd}
onEndReached={handleLoadMoreCatalogs}
onEndReachedThreshold={0.6}
scrollEventThrottle={32}
/>
{/* Toasts are rendered globally at root */}
</View>
@ -842,7 +819,7 @@ const styles = StyleSheet.create<any>({
placeholderPoster: {
width: POSTER_WIDTH,
aspectRatio: 2/3,
borderRadius: 4,
borderRadius: 12,
marginRight: 2,
},
emptyCatalog: {

View file

@ -197,13 +197,16 @@ class CatalogService {
// Create a promise for each catalog fetch
const catalogPromise = (async () => {
try {
const addonManifest = await stremioService.getInstalledAddonsAsync();
const manifest = addonManifest.find(a => a.id === addon.id);
// Hoist manifest list retrieval and find once
const addonManifests = await stremioService.getInstalledAddonsAsync();
const manifest = addonManifests.find(a => a.id === addon.id);
if (!manifest) return null;
const metas = await stremioService.getCatalog(manifest, catalog.type, catalog.id, 1);
if (metas && metas.length > 0) {
const items = metas.map(meta => this.convertMetaToStreamingContent(meta));
// Cap items per catalog to reduce memory and rendering load
const limited = metas.slice(0, 8); // Further reduced for memory
const items = limited.map(meta => this.convertMetaToStreamingContent(meta));
// Get potentially custom display name
let displayName = await getCatalogDisplayName(addon.id, catalog.type, catalog.id, catalog.name);