mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-03-11 17:45:38 +00:00
improved homescreen using flashlist
This commit is contained in:
parent
2feec37eb3
commit
a48cc0f2be
8 changed files with 209 additions and 190 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -44,3 +44,4 @@ HEATING_OPTIMIZATIONS.md
|
|||
ios
|
||||
android
|
||||
sliderreadme.md
|
||||
.cursor/mcp.json
|
||||
|
|
|
|||
|
|
@ -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?.();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
);
|
||||
});
|
||||
|
|
@ -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;
|
||||
});
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue