Refactor HomeScreen to use FlatList for improved performance and structure

This update replaces the ScrollView with a FlatList in the HomeScreen component, enhancing the rendering of catalog items and improving performance. The list data structure is also updated to include placeholders for loading states, ensuring a smoother user experience. Additionally, the logic for rendering list items and the footer component is optimized for better clarity and maintainability.
This commit is contained in:
tapframe 2025-06-21 01:19:34 +05:30
parent 5f8bb8948d
commit 152ec88e04

View file

@ -73,299 +73,16 @@ interface Category {
name: string;
}
interface ContentItemProps {
item: StreamingContent;
onPress: (id: string, type: string) => void;
}
interface DropUpMenuProps {
visible: boolean;
onClose: () => void;
item: StreamingContent;
onOptionSelect: (option: string) => void;
}
interface ContinueWatchingRef {
refresh: () => Promise<boolean>;
}
const DropUpMenu = React.memo(({ visible, onClose, item, onOptionSelect }: DropUpMenuProps) => {
const translateY = useSharedValue(300);
const opacity = useSharedValue(0);
const isDarkMode = useColorScheme() === 'dark';
const { currentTheme } = useTheme();
const SNAP_THRESHOLD = 100;
useEffect(() => {
if (visible) {
opacity.value = withTiming(1, { duration: 200 });
translateY.value = withTiming(0, { duration: 300 });
} else {
opacity.value = withTiming(0, { duration: 200 });
translateY.value = withTiming(300, { duration: 300 });
}
// Cleanup animations when component unmounts
return () => {
opacity.value = 0;
translateY.value = 300;
};
}, [visible]);
const gesture = useMemo(() => Gesture.Pan()
.onStart(() => {
// Store initial position if needed
})
.onUpdate((event) => {
if (event.translationY > 0) { // Only allow dragging downwards
translateY.value = event.translationY;
opacity.value = interpolate(
event.translationY,
[0, 300],
[1, 0],
Extrapolate.CLAMP
);
}
})
.onEnd((event) => {
if (event.translationY > SNAP_THRESHOLD || event.velocityY > 500) {
translateY.value = withTiming(300, { duration: 300 });
opacity.value = withTiming(0, { duration: 200 });
runOnJS(onClose)();
} else {
translateY.value = withTiming(0, { duration: 300 });
opacity.value = withTiming(1, { duration: 200 });
}
}), [onClose]);
const overlayStyle = useAnimatedStyle(() => ({
opacity: opacity.value,
backgroundColor: currentTheme.colors.transparentDark,
}));
const menuStyle = useAnimatedStyle(() => ({
transform: [{ translateY: translateY.value }],
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
backgroundColor: isDarkMode ? currentTheme.colors.elevation2 : currentTheme.colors.white,
}));
const menuOptions = useMemo(() => [
{
icon: item.inLibrary ? 'bookmark' : 'bookmark-border',
label: item.inLibrary ? 'Remove from Library' : 'Add to Library',
action: 'library'
},
{
icon: 'check-circle',
label: 'Mark as Watched',
action: 'watched'
},
{
icon: 'playlist-add',
label: 'Add to Playlist',
action: 'playlist'
},
{
icon: 'share',
label: 'Share',
action: 'share'
}
], [item.inLibrary]);
const handleOptionSelect = useCallback((action: string) => {
onOptionSelect(action);
onClose();
}, [onOptionSelect, onClose]);
return (
<Modal
visible={visible}
transparent
animationType="none"
onRequestClose={onClose}
>
<GestureHandlerRootView style={{ flex: 1 }}>
<Animated.View style={[styles.modalOverlay, overlayStyle]}>
<Pressable style={styles.modalOverlayPressable} onPress={onClose} />
<GestureDetector gesture={gesture}>
<Animated.View style={[styles.menuContainer, menuStyle]}>
<View style={[styles.dragHandle, { backgroundColor: currentTheme.colors.transparentLight }]} />
<View style={[styles.menuHeader, { borderBottomColor: currentTheme.colors.border }]}>
<ExpoImage
source={{ uri: item.poster }}
style={styles.menuPoster}
contentFit="cover"
/>
<View style={styles.menuTitleContainer}>
<Text style={[styles.menuTitle, { color: isDarkMode ? currentTheme.colors.white : currentTheme.colors.black }]}>
{item.name}
</Text>
{item.year && (
<Text style={[styles.menuYear, { color: isDarkMode ? currentTheme.colors.mediumEmphasis : currentTheme.colors.textMutedDark }]}>
{item.year}
</Text>
)}
</View>
</View>
<View style={styles.menuOptions}>
{menuOptions.map((option, index) => (
<TouchableOpacity
key={option.action}
style={[
styles.menuOption,
{ borderBottomColor: isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)' },
index === menuOptions.length - 1 && styles.lastMenuOption
]}
onPress={() => handleOptionSelect(option.action)}
>
<MaterialIcons
name={option.icon as "bookmark" | "check-circle" | "playlist-add" | "share" | "bookmark-border"}
size={24}
color={currentTheme.colors.primary}
/>
<Text style={[
styles.menuOptionText,
{ color: isDarkMode ? currentTheme.colors.white : currentTheme.colors.black }
]}>
{option.label}
</Text>
</TouchableOpacity>
))}
</View>
</Animated.View>
</GestureDetector>
</Animated.View>
</GestureHandlerRootView>
</Modal>
);
});
const ContentItem = React.memo(({ item: initialItem, onPress }: ContentItemProps) => {
const [menuVisible, setMenuVisible] = useState(false);
const [localItem, setLocalItem] = useState(initialItem);
const [isWatched, setIsWatched] = useState(false);
const [imageLoaded, setImageLoaded] = useState(false);
const [imageError, setImageError] = useState(false);
const { currentTheme } = useTheme();
const handleLongPress = useCallback(() => {
setMenuVisible(true);
}, []);
const handlePress = useCallback(() => {
onPress(localItem.id, localItem.type);
}, [localItem.id, localItem.type, onPress]);
const handleOptionSelect = useCallback((option: string) => {
switch (option) {
case 'library':
if (localItem.inLibrary) {
catalogService.removeFromLibrary(localItem.type, localItem.id);
} else {
catalogService.addToLibrary(localItem);
}
break;
case 'watched':
setIsWatched(prev => !prev);
break;
case 'playlist':
case 'share':
// These options don't have implementations yet
break;
}
}, [localItem]);
const handleMenuClose = useCallback(() => {
setMenuVisible(false);
}, []);
// Only update localItem when initialItem changes
useEffect(() => {
setLocalItem(initialItem);
}, [initialItem]);
// Subscribe to library updates
useEffect(() => {
const unsubscribe = catalogService.subscribeToLibraryUpdates((libraryItems) => {
const isInLibrary = libraryItems.some(
libraryItem => libraryItem.id === localItem.id && libraryItem.type === localItem.type
);
if (isInLibrary !== localItem.inLibrary) {
setLocalItem(prev => ({ ...prev, inLibrary: isInLibrary }));
}
});
return () => unsubscribe();
}, [localItem.id, localItem.type]);
return (
<>
<TouchableOpacity
style={styles.contentItem}
activeOpacity={0.7}
onPress={handlePress}
onLongPress={handleLongPress}
delayLongPress={300}
>
<View style={styles.contentItemContainer}>
<ExpoImage
source={{ uri: localItem.poster }}
style={styles.poster}
contentFit="cover"
transition={300}
cachePolicy="memory-disk"
recyclingKey={`poster-${localItem.id}`}
onLoadStart={() => {
setImageLoaded(false);
setImageError(false);
}}
onLoadEnd={() => setImageLoaded(true)}
onError={() => {
setImageError(true);
setImageLoaded(true);
}}
/>
{(!imageLoaded || imageError) && (
<View style={[styles.loadingOverlay, { backgroundColor: currentTheme.colors.elevation2 }]}>
{!imageError ? (
<ActivityIndicator color={currentTheme.colors.primary} size="small" />
) : (
<MaterialIcons name="broken-image" size={24} color={currentTheme.colors.lightGray} />
)}
</View>
)}
{isWatched && (
<View style={styles.watchedIndicator}>
<MaterialIcons name="check-circle" size={22} color={currentTheme.colors.success} />
</View>
)}
{localItem.inLibrary && (
<View style={styles.libraryBadge}>
<MaterialIcons name="bookmark" size={16} color={currentTheme.colors.white} />
</View>
)}
</View>
</TouchableOpacity>
{menuVisible && (
<DropUpMenu
visible={menuVisible}
onClose={handleMenuClose}
item={localItem}
onOptionSelect={handleOptionSelect}
/>
)}
</>
);
}, (prevProps, nextProps) => {
// Custom comparison function to prevent unnecessary re-renders
return (
prevProps.item.id === nextProps.item.id &&
prevProps.item.inLibrary === nextProps.item.inLibrary &&
prevProps.onPress === nextProps.onPress
);
});
type HomeScreenListItem =
| { type: 'featured'; key: string }
| { type: 'thisWeek'; key: string }
| { type: 'continueWatching'; key: string }
| { type: 'catalog'; catalog: CatalogContent; key: string }
| { type: 'placeholder'; key: string };
// Sample categories (real app would get these from API)
const SAMPLE_CATEGORIES: Category[] = [
@ -397,7 +114,7 @@ const HomeScreen = () => {
const refreshTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [hasContinueWatching, setHasContinueWatching] = useState(false);
const [catalogs, setCatalogs] = useState<CatalogContent[]>([]);
const [catalogs, setCatalogs] = useState<(CatalogContent | null)[]>([]);
const [catalogsLoading, setCatalogsLoading] = useState(true);
const [loadedCatalogCount, setLoadedCatalogCount] = useState(0);
const totalCatalogsRef = useRef(0);
@ -423,6 +140,9 @@ const HomeScreen = () => {
const catalogSettingsJson = await AsyncStorage.getItem(CATALOG_SETTINGS_KEY);
const catalogSettings = catalogSettingsJson ? JSON.parse(catalogSettingsJson) : {};
// Hoist addon manifest loading out of the loop
const addonManifests = await stremioService.getInstalledAddonsAsync();
// Create placeholder array with proper order and track indices
const catalogPlaceholders: (CatalogContent | null)[] = [];
const catalogPromises: Promise<void>[] = [];
@ -442,8 +162,7 @@ const HomeScreen = () => {
const catalogPromise = (async () => {
try {
const addonManifest = await stremioService.getInstalledAddonsAsync();
const manifest = addonManifest.find((a: any) => a.id === addon.id);
const manifest = addonManifests.find((a: any) => a.id === addon.id);
if (!manifest) return;
const metas = await stremioService.getCatalog(manifest, catalog.type, catalog.id, 1);
@ -737,6 +456,106 @@ const HomeScreen = () => {
return null;
}, [isLoading, currentTheme.colors]);
const listData: HomeScreenListItem[] = useMemo(() => {
const data: HomeScreenListItem[] = [];
if (showHeroSection) {
data.push({ type: 'featured', key: 'featured' });
}
data.push({ type: 'thisWeek', key: 'thisWeek' });
data.push({ type: 'continueWatching', key: 'continueWatching' });
catalogs.forEach((catalog, index) => {
if (catalog) {
data.push({ type: 'catalog', catalog, key: `${catalog.addon}-${catalog.id}-${index}` });
} else {
// Add a key for placeholders
data.push({ type: 'placeholder', key: `placeholder-${index}` });
}
});
return data;
}, [showHeroSection, catalogs]);
const renderListItem = useCallback(({ item }: { item: HomeScreenListItem }) => {
switch (item.type) {
case 'featured':
return (
<FeaturedContent
key={`featured-${showHeroSection}-${featuredContentSource}`}
featuredContent={featuredContent}
isSaved={isSaved}
handleSaveToLibrary={handleSaveToLibrary}
/>
);
case 'thisWeek':
return <Animated.View entering={FadeIn.duration(400).delay(150)}><ThisWeekSection /></Animated.View>;
case 'continueWatching':
return <ContinueWatchingSection ref={continueWatchingRef} />;
case 'catalog':
return (
<Animated.View entering={FadeIn.duration(300)}>
<CatalogSection catalog={item.catalog} />
</Animated.View>
);
case 'placeholder':
return (
<View style={styles.catalogPlaceholder}>
<View style={styles.placeholderHeader}>
<View style={[styles.placeholderTitle, { backgroundColor: currentTheme.colors.elevation1 }]} />
<ActivityIndicator size="small" color={currentTheme.colors.primary} />
</View>
<View style={styles.placeholderPosters}>
{[...Array(4)].map((_, posterIndex) => (
<View
key={posterIndex}
style={[styles.placeholderPoster, { backgroundColor: currentTheme.colors.elevation1 }]}
/>
))}
</View>
</View>
);
default:
return null;
}
}, [
showHeroSection,
featuredContentSource,
featuredContent,
isSaved,
handleSaveToLibrary,
currentTheme.colors
]);
const ListFooterComponent = useMemo(() => (
<>
{catalogsLoading && catalogs.length < totalCatalogsRef.current && (
<View style={styles.loadingMoreCatalogs}>
<ActivityIndicator size="small" color={currentTheme.colors.primary} />
<Text style={[styles.loadingMoreText, { color: currentTheme.colors.textMuted }]}>
Loading more content... ({loadedCatalogCount}/{totalCatalogsRef.current})
</Text>
</View>
)}
{!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} />
<Text style={{ color: currentTheme.colors.textDark, marginTop: 8, fontSize: 16, textAlign: 'center' }}>
No content available
</Text>
<TouchableOpacity
style={[styles.addCatalogButton, { backgroundColor: currentTheme.colors.primary }]}
onPress={() => navigation.navigate('Settings')}
>
<MaterialIcons name="add-circle" size={20} color={currentTheme.colors.white} />
<Text style={[styles.addCatalogButtonText, { color: currentTheme.colors.white }]}>Add Catalogs</Text>
</TouchableOpacity>
</View>
)}
</>
), [catalogsLoading, catalogs, loadedCatalogCount, totalCatalogsRef.current, navigation, currentTheme.colors]);
// Memoize the main content section
const renderMainContent = useMemo(() => {
if (isLoading) return null;
@ -748,102 +567,28 @@ const HomeScreen = () => {
backgroundColor="transparent"
translucent
/>
<ScrollView
<FlatList
data={listData}
renderItem={renderListItem}
keyExtractor={item => item.key}
contentContainerStyle={[
styles.scrollContent,
{ paddingTop: Platform.OS === 'ios' ? 100 : 90 }
]}
showsVerticalScrollIndicator={false}
removeClippedSubviews={true}
>
{showHeroSection && (
<FeaturedContent
key={`featured-${showHeroSection}-${featuredContentSource}`}
featuredContent={featuredContent}
isSaved={isSaved}
handleSaveToLibrary={handleSaveToLibrary}
/>
)}
<Animated.View entering={FadeIn.duration(400).delay(150)}>
<ThisWeekSection />
</Animated.View>
<ContinueWatchingSection ref={continueWatchingRef} />
{/* Show catalogs as they load */}
{catalogs.map((catalog, index) => {
if (!catalog) {
// Show placeholder for loading catalog
return (
<View key={`placeholder-${index}`} style={styles.catalogPlaceholder}>
<View style={styles.placeholderHeader}>
<View style={[styles.placeholderTitle, { backgroundColor: currentTheme.colors.elevation1 }]} />
<ActivityIndicator size="small" color={currentTheme.colors.primary} />
</View>
<View style={styles.placeholderPosters}>
{[...Array(4)].map((_, posterIndex) => (
<View
key={posterIndex}
style={[styles.placeholderPoster, { backgroundColor: currentTheme.colors.elevation1 }]}
/>
))}
</View>
</View>
);
}
return (
<Animated.View
key={`${catalog.addon}-${catalog.id}-${index}`}
entering={FadeIn.duration(300)}
>
<CatalogSection catalog={catalog} />
</Animated.View>
);
})}
{/* Show loading indicator for remaining catalogs */}
{catalogsLoading && catalogs.length < totalCatalogsRef.current && (
<View style={styles.loadingMoreCatalogs}>
<ActivityIndicator size="small" color={currentTheme.colors.primary} />
<Text style={[styles.loadingMoreText, { color: currentTheme.colors.textMuted }]}>
Loading more content... ({loadedCatalogCount}/{totalCatalogsRef.current})
</Text>
</View>
)}
{/* Show empty state only if all catalogs are loaded and none are available */}
{!catalogsLoading && catalogs.length === 0 && (
<View style={[styles.emptyCatalog, { backgroundColor: currentTheme.colors.elevation1 }]}>
<MaterialIcons name="movie-filter" size={40} color={currentTheme.colors.textDark} />
<Text style={{ color: currentTheme.colors.textDark, marginTop: 8, fontSize: 16, textAlign: 'center' }}>
No content available
</Text>
<TouchableOpacity
style={[styles.addCatalogButton, { backgroundColor: currentTheme.colors.primary }]}
onPress={() => navigation.navigate('Settings')}
>
<MaterialIcons name="add-circle" size={20} color={currentTheme.colors.white} />
<Text style={[styles.addCatalogButtonText, { color: currentTheme.colors.white }]}>Add Catalogs</Text>
</TouchableOpacity>
</View>
)}
</ScrollView>
ListFooterComponent={ListFooterComponent}
initialNumToRender={5}
maxToRenderPerBatch={5}
windowSize={10}
/>
</View>
);
}, [
isLoading,
currentTheme.colors,
showHeroSection,
featuredContent,
isSaved,
handleSaveToLibrary,
hasContinueWatching,
catalogs,
catalogsLoading,
navigation,
featuredContentSource
isLoading,
currentTheme.colors,
listData,
renderListItem,
ListFooterComponent
]);
return isLoading ? renderLoadingScreen : renderMainContent;