diff --git a/.gitignore b/.gitignore index cd33c432..b76da53b 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,4 @@ HEATING_OPTIMIZATIONS.md ios android sliderreadme.md +.cursor/mcp.json diff --git a/src/components/common/OptimizedImage.tsx b/src/components/common/OptimizedImage.tsx index d8c50cc7..545808b7 100644 --- a/src/components/common/OptimizedImage.tsx +++ b/src/components/common/OptimizedImage.tsx @@ -65,6 +65,7 @@ const OptimizedImage: React.FC = ({ 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(''); const mountedRef = useRef(true); const loadTimeoutRef = useRef(null); @@ -168,12 +169,15 @@ const OptimizedImage: React.FC = ({ } return ( - { setIsLoaded(true); onLoad?.(); diff --git a/src/components/home/CatalogSection.tsx b/src/components/home/CatalogSection.tsx index 8102e09a..70606c51 100644 --- a/src/components/home/CatalogSection.tsx +++ b/src/components/home/CatalogSection.tsx @@ -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>(); 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 ( - - - + ); - }; + }, [handleContentPress]); + + // Memoize the ItemSeparatorComponent to prevent re-creation + const ItemSeparator = useCallback(() => , []); + + // Memoize the keyExtractor to prevent re-creation + const keyExtractor = useCallback((item: StreamingContent) => `${item.id}-${item.type}`, []); return ( - + {catalog.name} @@ -98,34 +97,19 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => { - `${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={() => } - 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} /> - + ); }; @@ -178,4 +162,18 @@ const styles = StyleSheet.create({ }, }); -export default React.memo(CatalogSection); \ No newline at end of file +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 + ) + ); +}); \ No newline at end of file diff --git a/src/components/home/ContentItem.tsx b/src/components/home/ContentItem.tsx index d65e379f..f6aa10d5 100644 --- a/src/components/home/ContentItem.tsx +++ b/src/components/home/ContentItem.tsx @@ -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(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 ( <> @@ -98,26 +128,36 @@ const ContentItem = React.memo(({ item, onPress }: ContentItemProps) => { onLongPress={handleLongPress} delayLongPress={300} > - - { - setImageLoaded(true); - setImageError(false); - }} - onError={() => { - setImageError(true); - setImageLoaded(false); - }} - priority="low" - /> + + {/* Only load image when shouldLoadImage is true (lazy loading) */} + {shouldLoadImage && item.poster ? ( + { + setImageLoaded(true); + setImageError(false); + }} + onError={() => { + setImageError(true); + setImageLoaded(false); + }} + priority="low" + /> + ) : ( + // Show placeholder until lazy load triggers + + + {item.name.substring(0, 20)}... + + + )} {imageError && ( @@ -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; \ No newline at end of file +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; +}); \ No newline at end of file diff --git a/src/components/home/ContinueWatchingSection.tsx b/src/components/home/ContinueWatchingSection.tsx index 817de320..9493b8e3 100644 --- a/src/components/home/ContinueWatchingSection.tsx +++ b/src/components/home/ContinueWatchingSection.tsx @@ -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((props, re } return ( - + Continue Watching @@ -576,7 +576,7 @@ const ContinueWatchingSection = React.forwardRef((props, re - ( ((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((props, re {/* Delete Indicator Overlay */} {deletingItemId === item.id && ( - + - + )} @@ -691,12 +687,11 @@ const ContinueWatchingSection = React.forwardRef((props, re horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.wideList} - snapToInterval={280 + 16} // Card width + margin - decelerationRate="fast" - snapToAlignment="start" ItemSeparatorComponent={() => } + onEndReachedThreshold={0.7} + onEndReached={() => {}} /> - + ); }); @@ -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', diff --git a/src/components/home/ThisWeekSection.tsx b/src/components/home/ThisWeekSection.tsx index 892ffff6..d5b62415 100644 --- a/src/components/home/ThisWeekSection.tsx +++ b/src/components/home/ThisWeekSection.tsx @@ -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 ( - + { source={{ uri: imageUrl }} style={styles.poster} contentFit="cover" - transition={400} + transition={0} /> {/* Enhanced gradient overlay */} @@ -177,12 +174,12 @@ export const ThisWeekSection = React.memo(() => { - + ); }; return ( - + This Week @@ -206,7 +203,7 @@ export const ThisWeekSection = React.memo(() => { snapToAlignment="start" ItemSeparatorComponent={() => } /> - + ); }); diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index 0a8486dc..d2ef759a 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -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(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(() => ( + + ), [showHeroSection, featuredContentSource, featuredContent, isSaved, handleSaveToLibrary]); + + const memoizedThisWeekSection = useMemo(() => , []); + const memoizedContinueWatchingSection = useMemo(() => , []); + const renderListItem = useCallback(({ item }: { item: HomeScreenListItem }) => { switch (item.type) { - case 'featured': - return ( - - ); + // featured is rendered via ListHeaderComponent to avoid remounts case 'thisWeek': - return ; + return memoizedThisWeekSection; case 'continueWatching': - return ; + return memoizedContinueWatchingSection; case 'catalog': return ( - - - + ); case 'placeholder': return ( - - - - - - - - {[...Array(3)].map((_, posterIndex) => ( - - ))} - + + + + - + + {[...Array(3)].map((_, posterIndex) => ( + + ))} + + ); 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 && ( - - - - Loading catalogs... ({loadedCatalogCount}/{totalCatalogsRef.current}) - - - )} + {catalogsLoading && loadedCatalogCount > 0 && loadedCatalogCount < totalCatalogsRef.current && null} {!catalogsLoading && catalogs.filter(c => c).length === 0 && ( @@ -712,7 +696,7 @@ const HomeScreen = () => { backgroundColor="transparent" translucent /> - 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 */} @@ -842,7 +819,7 @@ const styles = StyleSheet.create({ placeholderPoster: { width: POSTER_WIDTH, aspectRatio: 2/3, - borderRadius: 4, + borderRadius: 12, marginRight: 2, }, emptyCatalog: { diff --git a/src/services/catalogService.ts b/src/services/catalogService.ts index af1f5e56..babd0fe4 100644 --- a/src/services/catalogService.ts +++ b/src/services/catalogService.ts @@ -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);